From 63ddc964decdd8ca5822b1e8b43ccd74b45205fb Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 9 Apr 2023 15:21:10 +0200 Subject: [PATCH 01/17] add a Variables class suitable for variable substitution include variable editors in the configuration editor --- nexxT/core/ConfigFileSchema.json | 15 +++- nexxT/core/Configuration.py | 41 ++++++---- nexxT/core/FilterMockup.py | 11 +-- nexxT/core/GuiStateSchema.json | 15 +++- nexxT/core/PropertyCollectionImpl.py | 91 +++++++++++++++------- nexxT/core/Utils.py | 17 ++++- nexxT/core/Variables.py | 101 ++++++++++++++++++++++++ nexxT/examples/framework/example.json | 74 ++++++++++++++---- nexxT/services/SrvConfiguration.py | 106 ++++++++++++++++++++++++++ nexxT/services/gui/Configuration.py | 25 +++++- nexxT/tests/core/test_Variables.py | 75 ++++++++++++++++++ 11 files changed, 500 insertions(+), 71 deletions(-) create mode 100644 nexxT/core/Variables.py create mode 100644 nexxT/tests/core/test_Variables.py diff --git a/nexxT/core/ConfigFileSchema.json b/nexxT/core/ConfigFileSchema.json index 16bd489..f0a24df 100644 --- a/nexxT/core/ConfigFileSchema.json +++ b/nexxT/core/ConfigFileSchema.json @@ -22,7 +22,20 @@ "type": "object", "propertyNames": { "$ref": "#/definitions/identifier" }, "patternProperties": { - "^.*$": {"type": ["string", "number", "boolean"]} + "^.*$": { + "anyOf": [ + {"type": "string"}, {"type": "number"}, {"type": "boolean"}, + { + "type": "object", + "additionalProperties": false, + "required": ["value", "subst"], + "properties": { + "value": {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}, + "subst": {"type": "boolean"} + } + } + ] + } } }, "sub_graph": { diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index a618efb..f7131d9 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -50,11 +50,25 @@ def configType(subConfig): return Configuration.CONFIG_TYPE_COMPOSITE raise NexTRuntimeError("Unexpected instance type") + def _defaultRootPropColl(self): + variables = None if not hasattr(self, "_propertyCollection") else self._propertyCollection.getVariables() + res = PropertyCollectionImpl("root", None, variables=variables) + vars = res.getVariables() + vars.setReadonly({}) + for v in list(vars.keys()): + del vars[v] + # setup the default variables available on all platforms + vars["CFG_DIR"] = "${!str(importlib.import_module('pathlib').Path(subst('$CFGFILE')).parent.absolute())}" + vars["NEXXT_PLATFORM"] = "${!importlib.import_module('nexxT.core.Utils').nexxtPlatform()}" + vars["NEXXT_VARIANT"] = "${!importlib.import_module('os').environ.get('NEXXT_VARIANT', 'release')}" + vars.setReadonly({"CFG_DIR", "NEXXT_PLATFORM", "NEXXT_VARIANT", "CFGFILE"}) + return res + def __init__(self): super().__init__() self._compositeFilters = [] self._applications = [] - self._propertyCollection = PropertyCollectionImpl("root", None) + self._propertyCollection = self._defaultRootPropColl() self._guiState = PropertyCollectionImpl("_guiState", self._propertyCollection) self._dirty = False @@ -95,7 +109,7 @@ def close(self, avoidSave=False): self.subConfigRemoved.emit(a.getName(), self.CONFIG_TYPE_APPLICATION) self._applications = [] self._propertyCollection.deleteLater() - self._propertyCollection = PropertyCollectionImpl("root", None) + self._propertyCollection = self._defaultRootPropColl() self.configNameChanged.emit(None) self.appActivated.emit("", None) PluginManager.singleton().unloadAll() @@ -112,9 +126,10 @@ def load(self, cfg): try: if cfg["CFGFILE"] is not None: # might happen during reload - self._propertyCollection.defineProperty("CFGFILE", cfg["CFGFILE"], - "The absolute path to the configuration file.", - options=dict(enum=[cfg["CFGFILE"]])) + vars = self._propertyCollection.getVariables() + origReadonly = vars.setReadonly([]) + vars["CFGFILE"] = cfg["CFGFILE"] + vars.setReadonly(origReadonly) try: self._propertyCollection.deleteChild("_guiState") except PropertyCollectionChildNotFound: @@ -160,12 +175,13 @@ def save(self, file=None): cfg = {} if file is not None: # TODO: we assume here that this is a new config; a "save to file" feature is not yet implemented. - self._propertyCollection.defineProperty("CFGFILE", str(file), - "The absolute path to the configuration file.", - options=dict(enum=[str(file)])) + vars = self._propertyCollection.getVariables() + origReadonly = vars.setReadonly([]) + vars["CFGFILE"] = cfg["CFGFILE"] + vars.setReadonly(origReadonly) try: - cfg["CFGFILE"] = self._propertyCollection.getProperty("CFGFILE") - except PropertyCollectionPropertyNotFound: + cfg["CFGFILE"] = self._propertyCollection.getVariables()["CFGFILE"] + except KeyError: cfg["CFGFILE"] = None cfg["_guiState"] = self._guiState.saveDict() cfg["composite_filters"] = [cf.save() for cf in self._compositeFilters] @@ -180,11 +196,10 @@ def filename(self): :return: """ try: - return self._propertyCollection.getProperty("CFGFILE") - except PropertyCollectionPropertyNotFound: + return self._propertyCollection.getVariables()["CFGFILE"] + except KeyError: return None - def propertyCollection(self): """ Get the (root) property collection. diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index 8bac493..4ba9fcd 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -34,16 +34,7 @@ def __init__(self, library, factoryFunction, propertyCollection, graph): self._propertyCollectionImpl = propertyCollection self._pluginClass = None self._createFilterAndUpdatePending = None - rootPc = propertyCollection - while rootPc.parent() is not None: - rootPc = rootPc.parent() - tmpRootPc = PropertyCollectionImpl("root", None) - try: - cfgfile = rootPc.getProperty("CFGFILE") - tmpRootPc.defineProperty("CFGFILE", cfgfile, "copy of original CFGFILE.", options=dict(enum=[cfgfile])) - except PropertyCollectionPropertyNotFound: - pass - tmpPc = PropertyCollectionImpl("temp", tmpRootPc) + tmpPc = PropertyCollectionImpl("__temp", propertyCollection) with FilterEnvironment(self._library, self._factoryFunction, tmpPc, self) as tmpEnv: self.updatePortInformation(tmpEnv) del tmpPc diff --git a/nexxT/core/GuiStateSchema.json b/nexxT/core/GuiStateSchema.json index 1c4c309..1801892 100644 --- a/nexxT/core/GuiStateSchema.json +++ b/nexxT/core/GuiStateSchema.json @@ -10,7 +10,20 @@ "type": "object", "propertyNames": { "$ref": "#/definitions/identifier" }, "patternProperties": { - "^.*$": {"type": ["string", "number", "boolean"]} + "^.*$": { + "anyOf": [ + {"type": "string"}, {"type": "number"}, {"type": "boolean"}, + { + "type": "object", + "additionalProperties": false, + "required": ["value", "subst"], + "properties": { + "value": {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}, + "subst": {"type": "boolean"} + } + } + ] + } } }, "sub_graph": { diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index a54fcf5..8a52c9f 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -21,6 +21,7 @@ PropertyInconsistentDefinition, PropertyCollectionPropertyNotFound) from nexxT.core.Utils import assertMainThread, checkIdentifier from nexxT.core.PropertyHandlers import defaultHandler +from nexxT.core.Variables import Variables from nexxT.interface import PropertyCollection, PropertyHandler logger = logging.getLogger(__name__) @@ -34,6 +35,7 @@ def __init__(self, defaultVal, helpstr, handler): self.value = defaultVal self.helpstr = helpstr self.handler = handler + self.useEnvironment = False self.used = True class PropertyCollectionImpl(PropertyCollection): @@ -48,10 +50,15 @@ class PropertyCollectionImpl(PropertyCollection): childRemoved = Signal(object, str) childRenamed = Signal(object, str, str) - def __init__(self, name, parentPropColl, loadedFromConfig=None): + def __init__(self, name, parentPropColl, loadedFromConfig=None, variables=None): PropertyCollection.__init__(self) assertMainThread() self._properties = {} + if variables is None: + self._vars = Variables(parent=parentPropColl._vars if parentPropColl is not None else None) # environment variables + else: + self._vars = variables + assert parentPropColl is None # this should be the root property self._accessed = False # if no access to properties has been made, we stick with configs from config file. self._loadedFromConfig = loadedFromConfig if loadedFromConfig is not None else {} self._propertyMutex = QRecursiveMutex() @@ -98,6 +105,12 @@ def getChildCollection(self, name): raise PropertyCollectionChildNotFound(name) return res[0] + def getVariables(self): + """ + Return the associated variables instance. + """ + return self._vars + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): """ Return the value of the given property, creating a new property if it doesn't exist. @@ -138,11 +151,20 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle p = self._properties[name] if name in self._loadedFromConfig: l = self._loadedFromConfig[name] - try: - p.value = p.handler.validate(p.handler.fromConfig(l)) - except Exception as e: - raise PropertyParsingError( - f"Error parsing property {name} from {repr(l)} (original exception: {str(e)})") from e + if isinstance(l, dict) and "subst" in l and "value" in l: + p.useEnvironment = l["subst"] + p.value = l["value"] + else: + p.useEnvironment = False + p.value = l + try: + if not p.useEnvironment: + p.value = p.handler.validate(p.handler.fromConfig(p.value)) + else: + p.handler.validate(self._vars.subst(p.value)) + except Exception as e: + raise PropertyParsingError( + f"Error parsing property {name} from {repr(l)} (original exception: {str(e)})") from e self.propertyAdded.emit(self, name) else: # the arguments to getProperty shall be consistent among calls @@ -167,6 +189,8 @@ def getProperty(self, name): raise PropertyCollectionPropertyNotFound(name) p = self._properties[name] p.used = True + if p.useEnvironment: + return p.handler.validate(self._vars.subst(p.value)) return p.value def getPropertyDetails(self, name): @@ -206,8 +230,27 @@ def setProperty(self, name, value): except Exception as e: raise PropertyParsingError( f"Error parsing property {name} from {repr(value)} (original exception: {str(e)})") from e - if value != p.value: + if value != p.value or p.useEnvironment: p.value = value + p.useEnvironment = False + self.propertyChanged.emit(self, name) + + @Slot(str, str) + def setVarProperty(self, name, value): + """ + Set the value of a named property using an variable substitution. + :param name: property name + :param value: the value to be set + :return: None + """ + self._accessed = True + with QMutexLocker(self._propertyMutex): + if name not in self._properties: + raise PropertyCollectionPropertyNotFound(name) + p = self._properties[name] + if value != p.value or not p.useEnvironment: + p.value = value + p.useEnvironment = True self.propertyChanged.emit(self, name) def markAllUnused(self): @@ -247,7 +290,10 @@ def saveDict(self): with QMutexLocker(self._propertyMutex): for n in sorted(self._properties): p = self._properties[n] - res[n] = p.handler.toConfig(p.value) + if p.useEnvironment: + res[n] = {"subst": True, "value": p.value} + else: + res[n] = {"subst": False, "value": p.handler.toConfig(p.value)} return res return self._loadedFromConfig @@ -266,6 +312,7 @@ def addChild(self, name, propColl): pass propColl.setObjectName(name) propColl.setParent(self) + propColl.getVariables().setParent(self.getVariables()) logger.internal("Propcoll %s: add child %s", self.objectName(), name) def renameChild(self, oldName, newName): @@ -306,23 +353,11 @@ def evalpath(self, path): :param path: a string :return: absolute path as string """ - root_prop = self - while root_prop.parent() is not None: - root_prop = root_prop.parent() - # substitute ${VAR} with environment variables - default_environ = dict( - NEXXT_VARIANT="release" - ) - if platform.system() == "Windows": - default_environ["NEXXT_PLATFORM"] = f"msvc_x86{'_64' if platform.architecture()[0] == '64bit' else ''}" - else: - default_environ["NEXXT_PLATFORM"] = f"linux_{platform.machine()}" - origpath = path - path = string.Template(path).safe_substitute({**default_environ, **os.environ}) - logger.debug("interpolated path %s -> %s", origpath, path) - if Path(path).is_absolute(): - return path - try: - return str((Path(root_prop.getProperty("CFGFILE")).parent / path).absolute()) - except PropertyCollectionPropertyNotFound: - return path + spath = self._vars.subst(path) + if spath != path or not Path(spath).is_absolute(): + logger.warning("Deprecated: Implicit substitution or relative paths to the config file. Consider to use " + "explicit variable substitution with ${CFG_DIR} to reference the directory of the config " + "file instead.") + if not Path(spath).is_absolute(): + spath = str((self._vars.subst("$CFG_DIR") / spath).absolute()) + return spath diff --git a/nexxT/core/Utils.py b/nexxT/core/Utils.py index bf7ec28..942028c 100644 --- a/nexxT/core/Utils.py +++ b/nexxT/core/Utils.py @@ -8,13 +8,14 @@ This module contains various small utility classes. """ +import datetime import io -import re -import sys import logging -import datetime import os.path +import platform +import re import sqlite3 +import sys import time from nexxT.Qt.QtCore import (QObject, Signal, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, QMutexLocker, QRecursiveMutex, QTimer, Qt, QPoint) @@ -452,3 +453,13 @@ def _paintEvent(self, event): painter.drawText(QPoint(0, y + fontMetrics.ascent()), elidedLastLine) break textLayout.endLayout() + +def nexxtPlatform(): + """ + Return the nexxT platform identifier as a string. + """ + if platform.system() == "Windows": + return f"msvc_x86{'_64' if platform.architecture()[0] == '64bit' else ''}" + elif platform.system() == "Linux": + return f"linux_{platform.machine()}" + raise RuntimeError("Unknown system: " + platform.system()) diff --git a/nexxT/core/Variables.py b/nexxT/core/Variables.py new file mode 100644 index 0000000..e6662ad --- /dev/null +++ b/nexxT/core/Variables.py @@ -0,0 +1,101 @@ +import importlib +import logging +import string +from collections import UserDict +from nexxT.Qt.QtCore import QObject, Signal + +logger = logging.getLogger(__name__) + +class Variables(QObject): + + class VarDict(UserDict): + def __init__(self, variables): + self._variables = variables + super().__init__() + + def __getitem__(self, key): + key = key.upper() + try: + res = self.data[key] + if res.startswith("${!") and res.endswith("}"): + try: + res = str(eval(res[3:-1], {'importlib': importlib, 'subst': self._variables.subst})) + except Exception as e: + logger.warning("An error occurred while substituting '%s' evaluating to python code '%s': %s", + key, res[3:-1], e) + res = f"<{str(e)}>" + res = self._variables.subst(res) + return res + except KeyError as e: + if self._variables._parent is not None: + return self._variables._parent[key] + raise e + + def getraw(self, key): + return self.data[key] + + def __setitem__(self, key, value): + key = key.upper() + if key in self._variables._readonly and self.data[key] != value: + raise RuntimeError("Trying to modify readonly variable %s." % key) + self.data[key] = value + self._variables.variableAddedOrChanged.emit(key, value) + + def __delitem__(self, key): + key = key.upper() + super().__delitem__(key) + self._variables.variableDeleted.emit(key) + + """ + This class represents a collection of variables suitable for substitution in properties of a filter. + + Substitution is performed according to the following rules: + 1. Variables are arranged in a tree structure. During substitution the tree is searched upwards until a variable is + found. + 2. The syntax for substitution is basically the same as string.Template(value).safe_substitute. + 3. Variables are always converted to upper case strings before substituting (i.e. they are case insensitive). + 4. In case a variable is expanded to '${!python_code}', the python_code is evaluated using eval(...). The result is + converted to a string, and the substitution proceeds (recursive substitution is possible). In case the python code + raises an exception e, the substitution result is f'<{str(e)}' and a warning is logged. Substitution might use + the importlib module to import modules and the special function subst(template) for substituting variables. + """ + + variableAddedOrChanged = Signal(str, str) + variableDeleted = Signal(str) + + def __init__(self, parent = None): + self._parent = parent + self._readonly = set() + self._vars = Variables.VarDict(self) + super().__init__() + + def setParent(self, parent): + self._parent = parent + + def subst(self, content): + return string.Template(content).safe_substitute(self) + + def setReadonly(self, readonlyvars): + old = self._readonly + self._readonly = set() + for k in readonlyvars: + self._readonly.add(k.upper()) + return old + + def isReadonly(self, key): + return key.upper() in self._readonly + + def keys(self): + return self._vars.keys() + + def getraw(self, key): + return self._vars.getraw(key) + + def __setitem__(self, key, value): + self._vars[key] = value + + def __getitem__(self, key): + return self._vars[key] + + def __delitem__(self, key): + del self._vars[key] \ No newline at end of file diff --git a/nexxT/examples/framework/example.json b/nexxT/examples/framework/example.json index 8dbd153..35bb3c4 100644 --- a/nexxT/examples/framework/example.json +++ b/nexxT/examples/framework/example.json @@ -43,8 +43,14 @@ "staticOutputPorts": [], "thread": "main", "properties": { - "caption": "Processed", - "scale": 0.5 + "caption": { + "subst": false, + "value": "Processed" + }, + "scale": { + "subst": false, + "value": 0.5 + } } }, { @@ -59,8 +65,14 @@ "staticOutputPorts": [], "thread": "main", "properties": { - "caption": "Original", - "scale": 0.5 + "caption": { + "subst": false, + "value": "Original" + }, + "scale": { + "subst": false, + "value": 0.5 + } } } ], @@ -112,7 +124,12 @@ "video_out" ], "thread": "grabber", - "properties": {} + "properties": { + "device": { + "subst": false, + "value": "HP HD Camera: HP HD Camera" + } + } }, { "name": "ImageBlur", @@ -128,7 +145,10 @@ ], "thread": "compute", "properties": { - "kernelSize": 9 + "kernelSize": { + "subst": false, + "value": 9 + } } }, { @@ -143,12 +163,30 @@ "staticOutputPorts": [], "thread": "writer", "properties": { - "buffer_period": 1.0, - "buffer_samples": 0, - "filename": "${DATE}_${TIME}_${FILTER_NAME}.h5", - "silent_overwrite": false, - "use_posix_fadvise_if_available": true, - "use_receive_timestamps": true + "buffer_period": { + "subst": false, + "value": 1.0 + }, + "buffer_samples": { + "subst": false, + "value": 0 + }, + "filename": { + "subst": false, + "value": "${DATE}_${TIME}_${FILTER_NAME}.h5" + }, + "silent_overwrite": { + "subst": false, + "value": false + }, + "use_posix_fadvise_if_available": { + "subst": false, + "value": true + }, + "use_receive_timestamps": { + "subst": false, + "value": true + } } }, { @@ -200,7 +238,10 @@ ], "thread": "compute", "properties": { - "kernelSize": 9 + "kernelSize": { + "subst": false, + "value": 9 + } } }, { @@ -228,7 +269,12 @@ ], "staticOutputPorts": [], "thread": "reader", - "properties": {} + "properties": { + "defaultStepStream": { + "subst": false, + "value": "" + } + } }, { "name": "AviReader", diff --git a/nexxT/services/SrvConfiguration.py b/nexxT/services/SrvConfiguration.py index d1af877..63a6c01 100644 --- a/nexxT/services/SrvConfiguration.py +++ b/nexxT/services/SrvConfiguration.py @@ -17,6 +17,7 @@ from nexxT.core.Configuration import Configuration from nexxT.core.Application import Application from nexxT.core.CompositeFilter import CompositeFilter +from nexxT.core.Variables import Variables from nexxT.core.Utils import assertMainThread, handleException, mainThread, MethodInvoker from nexxT.interface.Filters import FilterState from nexxT.interface import Services @@ -74,6 +75,14 @@ class SubConfigContent: def __init__(self, subConfig): self.subConfig = subConfig + class VariableContent: + """ + A variable definition in a Variables instance, for usage within the model. + """ + def __init__(self, name, variables): + self.name = name + self.variables = variables + # Model implementation def __init__(self, configuration, parent): @@ -81,10 +90,12 @@ def __init__(self, configuration, parent): self.root = self.Item(None, configuration) self.Item(self.root, "composite") self.Item(self.root, "apps") + vitem = self.Item(self.root, configuration.propertyCollection().getVariables()) self.activeApp = None configuration.subConfigAdded.connect(self.subConfigAdded) configuration.subConfigRemoved.connect(self.subConfigRemoved) configuration.appActivated.connect(self.appActivated) + self._connectVariables(vitem) def isSubConfigParent(self, index): """ @@ -165,6 +176,21 @@ def indexOfNode(self, subConfig, node): raise NexTRuntimeError("Unable to locate node.") return self.index(idx[0], 0, parent) + def indexOfVariable(self, vitem): + def _traverse(parent): + for r in range(self.rowCount(parent)): + index = self.index(r, 0, parent) + if index.internalPointer() is vitem: + return index + ret = _traverse(index) + if ret is not None: + return ret + return None + res = _traverse(QModelIndex()) + if res is None: + raise NexTRuntimeError("Cannot locate variable item in model.") + return res + def subConfigByNameAndType(self, name, sctype): """ Returns a SubConfiguration instance, given its name and type @@ -204,6 +230,48 @@ def subConfigAdded(self, subConfig): self.Item(parentItem, self.SubConfigContent(subConfig)) self.endInsertRows() + def _connectVariables(self, vitem): + variables = vitem.content + for vname in variables.keys(): + self.variableAddedOrChanged(vitem, vname, variables) + variables.variableAddedOrChanged.connect( + lambda key, _value, self=self, vitem=vitem, variables=variables: + self.variableAddedOrChanged(vitem, key, variables)) + variables.variableDeleted.connect( + lambda key, self=self, vitem=vitem: + self.variableDeleted(vitem, key)) + + def variableAddedOrChanged(self, parentItem, key, variables): + parent = self.indexOfVariable(parentItem) + assert parent.internalPointer() is parentItem + found = False + for r in range(self.rowCount(parent)): + vcontent_key = self.index(r, 0, parent) + vcontent_value = self.index(r, 1, parent) + vcontent = vcontent_key.internalPointer() + assert isinstance(vcontent, self.Item), repr(vcontent) + assert isinstance(vcontent.content, self.VariableContent) + if vcontent.content.name == key: + self.dataChanged.emit(vcontent_value, vcontent_value) + found = True + if not found: + # var was added + self.beginInsertRows(parent, len(parentItem.children), len(parentItem.children)) + item = self.Item(parentItem, self.VariableContent(key, variables)) + self.endInsertRows() + + def variableDeleted(self, vitem, key): + assert isinstance(vitem, ConfigurationModel.Item) + parent = self.indexOfVariable(vitem) + for row, v in enumerate(vitem.children): + assert isinstance(v, ConfigurationModel.Item) and isinstance(v.content, ConfigurationModel.VariableContent) + if v.content.name == key: + self.beginRemoveRows(parent, row, row) + vitem.children = vitem.children[:row] + vitem.children[row+1:] + self.endRemoveRows() + return + raise RuntimeError("did not find matching variables object to be deleted.") + @Slot(object) def subConfigRenamed(self, subConfig, oldName): # pylint: disable=unused-argument """ @@ -284,6 +352,13 @@ def nodeAdded(self, subConfig, node): logger.debug("register propColl: %s", propColl) for pname in propColl.getAllPropertyNames(): self.propertyAdded(item, propColl, pname) + if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): + # add a variable editor + nodeIndex = self.index(len(parentItem.children)-1,0,parent) + self.beginInsertRows(nodeIndex, len(item.children), len(item.children)) + vitem = self.Item(item, propColl.getVariables()) + self.endInsertRows() + self._connectVariables(vitem) propColl.propertyAdded.connect(lambda pc, name: self.propertyAdded(item, pc, name)) propColl.propertyRemoved.connect(lambda pc, name: self.propertyRemoved(item, pc, name)) propColl.propertyChanged.connect(lambda pc, name: self.propertyChanged(item, pc, name)) @@ -437,6 +512,8 @@ def columnCount(self, parent): parentItem = parent.internalPointer() if isinstance(parentItem.content, self.NodeContent): return 2 # nodes children have the editable properties + if isinstance(parentItem.content, Variables): + return 2 return 1 return 2 @@ -471,6 +548,19 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma return item.name p = item.property.getPropertyDetails(item.name) return p.handler.toViewValue(item.property.getProperty(item.name)) + if isinstance(item, Variables): + if index.column() == 0: + return "variables" + return None + if isinstance(item, self.VariableContent): + if index.column() == 0: + return item.name + try: + value = item.variables.getraw(item.name) + return value + except KeyError: + # this might happen when a variable is already deleted and the model updates itself + return "" logger.warning("Unknown item %s", repr(item)) if role == Qt.DecorationRole: if index.column() != 0: @@ -487,6 +577,10 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma return QIcon.fromTheme("unknown", QApplication.style().standardIcon(QStyle.SP_FileIcon)) if isinstance(item, self.PropertyContent): return None + if isinstance(item, Variables): + return QIcon.fromTheme("unknown", QApplication.style().standardIcon(QStyle.SP_DirIcon)) + if isinstance(item, self.VariableContent): + return None logger.warning("Unknown item %s", repr(item)) if role == Qt.FontRole: if index.column() != 0: @@ -501,6 +595,8 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma if isinstance(item, self.PropertyContent): p = item.property.getPropertyDetails(item.name) return p.helpstr + if isinstance(item, self.VariableContent): + return item.variables.subst(f"{item.name} = ${item.name}") if role == ITEM_ROLE: return item return None @@ -527,6 +623,9 @@ def flags(self, index): # pylint: disable=too-many-return-statements,too-many-br if index.column() == 0: return Qt.ItemIsEnabled return Qt.ItemIsEnabled | Qt.ItemIsEditable + if isinstance(item, self.VariableContent): + if not item.variables.isReadonly(item.name) and index.column() == 1: + return Qt.ItemIsEnabled | Qt.ItemIsEditable return Qt.ItemIsEnabled def setData(self, index, value, role):# pylint: disable=too-many-return-statements,too-many-branches,unused-argument @@ -582,6 +681,13 @@ def setData(self, index, value, role):# pylint: disable=too-many-return-statemen return False self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) return True + if isinstance(item, self.VariableContent): + try: + item.variables[item.name] = value + except NexTRuntimeError: + return False + self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) + return True return False def headerDate(self, section, orientation, role): diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index d4c91fa..6039372 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -13,10 +13,11 @@ from nexxT.Qt.QtCore import (Qt, QSettings, QByteArray, QDataStream, QIODevice, QTimer) from nexxT.Qt.QtGui import QIcon, QKeySequence, QAction from nexxT.Qt.QtWidgets import (QTreeView, QStyle, QApplication, QFileDialog, QAbstractItemView, QMessageBox, - QHeaderView, QMenu, QDockWidget) + QHeaderView, QMenu, QDockWidget, QInputDialog) from nexxT.interface import Services, FilterState from nexxT.core.Configuration import Configuration from nexxT.core.Application import Application +from nexxT.core.Variables import Variables from nexxT.core.Utils import assertMainThread, MethodInvoker, mainThread, handleException from nexxT.services.SrvConfiguration import MVCConfigurationBase, ConfigurationModel, ITEM_ROLE from nexxT.services.gui.PropertyDelegate import PropertyDelegate @@ -313,6 +314,28 @@ def _execTreeViewContextMenu(self, point): if a is not None: self._configuration.addNewCompositeFilter() return + if isinstance(item, Variables): + m = QMenu() + a = QAction("Add variable ...") + m.addAction(a) + a = nexxT.Qt.call_exec(m, self.treeView.mapToGlobal(point)) + if a is not None: + vname, ok = QInputDialog.getText( + self.treeView, "Add Variable", "Variable Name", text="VARIABLE", + inputMethodHints=Qt.InputMethodHint.ImhUppercaseOnly|Qt.InputMethodHint.ImhLatinOnly) + variables = item + if ok and vname is not None and vname != "" and vname not in variables.keys(): + variables[vname] = "" + return + if isinstance(item, ConfigurationModel.VariableContent): + if not item.variables.isReadonly(item.name): + m = QMenu() + a = QAction("Remove variable") + m.addAction(a) + a = nexxT.Qt.call_exec(m, self.treeView.mapToGlobal(point)) + if a is not None: + del item.variables[item.name] + return def _configNameChanged(self, cfgfile): logger.debug("_configNameChanged: %s", cfgfile) diff --git a/nexxT/tests/core/test_Variables.py b/nexxT/tests/core/test_Variables.py new file mode 100644 index 0000000..b1ecc0e --- /dev/null +++ b/nexxT/tests/core/test_Variables.py @@ -0,0 +1,75 @@ +import math +from nexxT.core.Variables import Variables + +def test_standardSubstitution(): + v = Variables() + v["var1"] = "Hello World" + v["var2"] = "$var1" + v["var3"] = "$var3" + v["var4"] = "${!importlib.import_module('math').exp(1)}" + v["var5"] = "${!subst('$var1')}" + v["var6"] = "${!xxx}" + # unfortunately this is not directly possible due to the usage of string.Template. + # you need a seperate variable defining only the python code, like var4 + v["var7"] = "exp(1) is ${!importlib.import_module('math').exp(1)}" + + assert v.subst("var1 is '$var1'") == "var1 is 'Hello World'" + assert v.subst("var2 is '$var2'") == "var2 is 'Hello World'" + try: + v.subst("var3 is '$var3'") + assert False + except RecursionError: + assert True + assert v.subst("var4 is '$var4'") == f"var4 is '{math.exp(1)}'" + assert v.subst("var5 is '$var5'") == f"var5 is 'Hello World'" + assert v.subst("var1 is '${var1}'") == f"var1 is 'Hello World'" + v.subst("var6 is '$var6'") == "var6 is ''" + assert v.subst("var7 is '$var7'") == "var7 is 'exp(1) is ${!importlib.import_module('math').exp(1)}'" + +def test_treeStructure(): + root = Variables() + child1 = Variables(root) + child2 = Variables(root) + grandchild = Variables(child1) + + root["id"] = "root" + root["root"] = "$id" + root["python"] = "${!subst('$id')}" + root["python_nonexist"] = "${!subst('$child1')}" + child1["id"] = "child1" + child1["child1"] = "$id" + child2["id"] = "child2" + child2["child2"] = "$id" + grandchild["id"] = "grandchild" + grandchild["grandchild"] = "$id" + + assert root.subst("$id") == "root" + assert child1.subst("$id") == "child1" + assert child2.subst("$id") == "child2" + assert grandchild.subst("$id") == "grandchild" + + assert root.subst("$python_nonexist") == "$child1" + assert child1.subst("$python_nonexist") == "$child1" + assert child2.subst("$python_nonexist") == "$child1" + assert grandchild.subst("$python_nonexist") == "$child1" + + assert root.subst("$python") == "root" + assert child1.subst("$python") == "root" + assert child2.subst("$python") == "root" + assert grandchild.subst("$python") == "root" + + assert child1.subst("$root") == "root" + assert child1.subst("$child1") == "child1" + assert child1.subst("$child2") == "$child2" + assert child1.subst("$grandchild") == "$grandchild" + + assert child2.subst("$root") == "root" + assert child2.subst("$child1") == "$child1" + assert child2.subst("$child2") == "child2" + assert child2.subst("$grandchild") == "$grandchild" + + assert grandchild.subst("$root") == "root" + assert grandchild.subst("$child1") == "child1" + assert grandchild.subst("$child2") == "$child2" + assert grandchild.subst("$grandchild") == "grandchild" + From 7bcdbe6b69ce8a193c4b59b219553a49181bc715 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sat, 1 Jul 2023 18:06:08 +0200 Subject: [PATCH 02/17] first version of gui with configurable substitution --- nexxT/core/BaseGraph.py | 2 +- nexxT/core/Configuration.py | 34 +- nexxT/core/FilterMockup.py | 3 +- nexxT/core/PluginManager.py | 2 + nexxT/core/PropertyCollectionImpl.py | 15 +- nexxT/core/PropertyHandlers.py | 27 +- nexxT/core/Utils.py | 2 +- nexxT/core/Variables.py | 84 ++- nexxT/core/qrc_resources.py | 556 +++++++++--------- nexxT/services/SrvConfiguration.py | 84 ++- nexxT/services/gui/Configuration.py | 8 +- nexxT/services/gui/GraphEditor.py | 9 + nexxT/services/gui/PropertyDelegate.py | 50 +- nexxT/tests/core/test_EntryPoints.py | 4 +- .../tests/core/test_PropertyCollectionImpl.py | 4 +- nexxT/tests/integration/basicworkflow.json | 5 + 16 files changed, 518 insertions(+), 371 deletions(-) create mode 100644 nexxT/tests/integration/basicworkflow.json diff --git a/nexxT/core/BaseGraph.py b/nexxT/core/BaseGraph.py index 85317c8..7d8d15d 100644 --- a/nexxT/core/BaseGraph.py +++ b/nexxT/core/BaseGraph.py @@ -117,7 +117,7 @@ def renameNode(self, oldName, newName): self._connections[i] = c p = self._connectionProps[oldConn] del self._connectionProps[oldConn] - self._connectionProps[c] = p + self._connectionProps[c] = p self.nodeRenamed.emit(oldName, newName) self.dirtyChanged.emit() diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index f7131d9..dcabccf 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -13,7 +13,7 @@ from nexxT.core.Application import Application from nexxT.core.CompositeFilter import CompositeFilter from nexxT.core.Exceptions import (NexTRuntimeError, CompositeRecursion, NodeNotFoundError, NexTInternalError, - PropertyCollectionPropertyNotFound, PropertyCollectionChildNotFound) + PropertyCollectionChildNotFound) from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl from nexxT.core.PluginManager import PluginManager from nexxT.core.ConfigFiles import ConfigFileLoader @@ -53,15 +53,15 @@ def configType(subConfig): def _defaultRootPropColl(self): variables = None if not hasattr(self, "_propertyCollection") else self._propertyCollection.getVariables() res = PropertyCollectionImpl("root", None, variables=variables) - vars = res.getVariables() - vars.setReadonly({}) - for v in list(vars.keys()): - del vars[v] + theVars = res.getVariables() + theVars.setReadonly({}) + for v in list(theVars.keys()): + del theVars[v] # setup the default variables available on all platforms - vars["CFG_DIR"] = "${!str(importlib.import_module('pathlib').Path(subst('$CFGFILE')).parent.absolute())}" - vars["NEXXT_PLATFORM"] = "${!importlib.import_module('nexxT.core.Utils').nexxtPlatform()}" - vars["NEXXT_VARIANT"] = "${!importlib.import_module('os').environ.get('NEXXT_VARIANT', 'release')}" - vars.setReadonly({"CFG_DIR", "NEXXT_PLATFORM", "NEXXT_VARIANT", "CFGFILE"}) + theVars["CFG_DIR"] = "${!str(importlib.import_module('pathlib').Path(subst('$CFGFILE')).parent.absolute())}" + theVars["NEXXT_PLATFORM"] = "${!importlib.import_module('nexxT.core.Utils').nexxtPlatform()}" + theVars["NEXXT_VARIANT"] = "${!importlib.import_module('os').environ.get('NEXXT_VARIANT', 'release')}" + theVars.setReadonly({"CFG_DIR", "NEXXT_PLATFORM", "NEXXT_VARIANT", "CFGFILE"}) return res def __init__(self): @@ -126,10 +126,10 @@ def load(self, cfg): try: if cfg["CFGFILE"] is not None: # might happen during reload - vars = self._propertyCollection.getVariables() - origReadonly = vars.setReadonly([]) - vars["CFGFILE"] = cfg["CFGFILE"] - vars.setReadonly(origReadonly) + theVars = self._propertyCollection.getVariables() + origReadonly = theVars.setReadonly([]) + theVars["CFGFILE"] = cfg["CFGFILE"] + theVars.setReadonly(origReadonly) try: self._propertyCollection.deleteChild("_guiState") except PropertyCollectionChildNotFound: @@ -175,10 +175,10 @@ def save(self, file=None): cfg = {} if file is not None: # TODO: we assume here that this is a new config; a "save to file" feature is not yet implemented. - vars = self._propertyCollection.getVariables() - origReadonly = vars.setReadonly([]) - vars["CFGFILE"] = cfg["CFGFILE"] - vars.setReadonly(origReadonly) + theVars = self._propertyCollection.getVariables() + origReadonly = theVars.setReadonly([]) + theVars["CFGFILE"] = file + theVars.setReadonly(origReadonly) try: cfg["CFGFILE"] = self._propertyCollection.getVariables()["CFGFILE"] except KeyError: diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index 4ba9fcd..91bfd59 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -13,8 +13,7 @@ from nexxT.interface import InputPort, OutputPort, InputPortInterface, OutputPortInterface from nexxT.core.FilterEnvironment import FilterEnvironment from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl -from nexxT.core.Exceptions import (PortNotFoundError, PortExistsError, PropertyCollectionChildExists, - PropertyCollectionPropertyNotFound) +from nexxT.core.Exceptions import PortNotFoundError, PortExistsError, PropertyCollectionChildExists from nexxT.core.Utils import assertMainThread, MethodInvoker import nexxT diff --git a/nexxT/core/PluginManager.py b/nexxT/core/PluginManager.py index f1a9b4a..37a90e7 100644 --- a/nexxT/core/PluginManager.py +++ b/nexxT/core/PluginManager.py @@ -284,6 +284,7 @@ def _load(self, library): @staticmethod def _loadPyfile(library, prop=None): if prop is not None: + library = prop.getVariables().subst(library) library = prop.evalpath(library) return PythonLibrary(library, libtype=PythonLibrary.LIBTYPE_FILE) @@ -300,6 +301,7 @@ def _loadBinary(library, prop=None): if PluginInterface is None: raise UnknownPluginType("binary plugins can only be loaded with c extension enabled.") if prop is not None: + library = prop.getVariables().subst(library) library = prop.evalpath(library) else: logger.warning("no property collection instance, string interpolation skipped.") diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index 8a52c9f..e1d7c9c 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -11,9 +11,6 @@ from collections import OrderedDict from pathlib import Path import logging -import platform -import string -import os import nexxT.shiboken from nexxT.Qt.QtCore import Signal, Slot, QRecursiveMutex, QMutexLocker from nexxT.core.Exceptions import (PropertyCollectionChildNotFound, PropertyCollectionChildExists, @@ -55,7 +52,7 @@ def __init__(self, name, parentPropColl, loadedFromConfig=None, variables=None): assertMainThread() self._properties = {} if variables is None: - self._vars = Variables(parent=parentPropColl._vars if parentPropColl is not None else None) # environment variables + self._vars = Variables(parent=parentPropColl._vars if parentPropColl is not None else None) else: self._vars = variables assert parentPropColl is None # this should be the root property @@ -182,14 +179,14 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle return p.value @Slot(str) - def getProperty(self, name): + def getProperty(self, name, subst=True): self._accessed = True with QMutexLocker(self._propertyMutex): if name not in self._properties: raise PropertyCollectionPropertyNotFound(name) p = self._properties[name] p.used = True - if p.useEnvironment: + if p.useEnvironment and subst: return p.handler.validate(self._vars.subst(p.value)) return p.value @@ -216,6 +213,7 @@ def getAllPropertyNames(self): def setProperty(self, name, value): """ Set the value of a named property. + :param name: property name :param value: the value to be set :return: None @@ -239,6 +237,7 @@ def setProperty(self, name, value): def setVarProperty(self, name, value): """ Set the value of a named property using an variable substitution. + :param name: property name :param value: the value to be set :return: None @@ -357,7 +356,7 @@ def evalpath(self, path): if spath != path or not Path(spath).is_absolute(): logger.warning("Deprecated: Implicit substitution or relative paths to the config file. Consider to use " "explicit variable substitution with ${CFG_DIR} to reference the directory of the config " - "file instead.") + "file instead. Found while evaluating %s.", path) if not Path(spath).is_absolute(): - spath = str((self._vars.subst("$CFG_DIR") / spath).absolute()) + spath = str((Path(self._vars.subst("$CFG_DIR")) / spath).absolute()) return spath diff --git a/nexxT/core/PropertyHandlers.py b/nexxT/core/PropertyHandlers.py index 35e8f97..6011607 100644 --- a/nexxT/core/PropertyHandlers.py +++ b/nexxT/core/PropertyHandlers.py @@ -79,6 +79,12 @@ def validate(self, value): :param value: the value to be tested (an integer) :return: the adapted, valid value """ + if isinstance(value, str): + try: + value = int(value) + except ValueError: + logger.warning("Cannot interpret value '%s' as int. Using 0.", value) + value = 0 if "min" in self._options: if value < self._options["min"]: logger.warning("Adapted option value %d to minimum value %d.", value, self._options["min"]) @@ -189,7 +195,7 @@ def validate(self, value): """ if "enum" in self._options: if not value in self._options["enum"]: - logger.warning("Enum validation failed. Using first value in allowed list.") + logger.warning("Enum validation failed for '%s'. Using first value in allowed list.", value) return self._options["enum"][0] return str(value) @@ -284,6 +290,12 @@ def validate(self, value): :param value: the value to be tested (a float) :return: the adapted, valid value """ + if isinstance(value, str): + try: + value = float(value) + except ValueError: + logger.warning("Cannot interpret value '%s' as float. Using 0.0.", value) + value = 0.0 if "min" in self._options: if value < self._options["min"]: logger.warning("Adapted option value %f to minimum value %f.", value, self._options["min"]) @@ -330,7 +342,7 @@ class BoolHandler(PropertyHandler): def __init__(self, options): for k in options: - raise PropertyParsingError(f"Unexpected option {k}; expected 'min' or 'max'.") + raise PropertyParsingError(f"Unexpected option {k}.") self._options = options def options(self): @@ -374,7 +386,16 @@ def validate(self, value): :return: the adapted, valid value """ if isinstance(value, str): - return value.lower() == "true" + if value.lower() == "true": + return True + if value.lower() == "false": + return False + try: + value = float(value) + return bool(value) + except ValueError: + logger.warning("Cannot interpret value '%s' as bool. Using false.", value) + return False return bool(value) def createEditor(self, parent): diff --git a/nexxT/core/Utils.py b/nexxT/core/Utils.py index 942028c..53b617b 100644 --- a/nexxT/core/Utils.py +++ b/nexxT/core/Utils.py @@ -460,6 +460,6 @@ def nexxtPlatform(): """ if platform.system() == "Windows": return f"msvc_x86{'_64' if platform.architecture()[0] == '64bit' else ''}" - elif platform.system() == "Linux": + if platform.system() == "Linux": return f"linux_{platform.machine()}" raise RuntimeError("Unknown system: " + platform.system()) diff --git a/nexxT/core/Variables.py b/nexxT/core/Variables.py index e6662ad..76f04df 100644 --- a/nexxT/core/Variables.py +++ b/nexxT/core/Variables.py @@ -1,3 +1,13 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2020 ifm electronic gmbh +# +# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# + +""" +This module implements a variable system for property substitution in nexxT +""" + import importlib import logging import string @@ -7,8 +17,25 @@ logger = logging.getLogger(__name__) class Variables(QObject): + """ + This class represents a collection of variables suitable for substitution in properties of a filter. + + Substitution is performed according to the following rules: + 1. Variables are arranged in a tree structure. During substitution the tree is searched upwards until a variable is + found. + 2. The syntax for substitution is basically the same as string.Template(value).safe_substitute. + 3. Variables are always converted to upper case strings before substituting (i.e. they are case insensitive). + 4. In case a variable is expanded to '${!python_code}', the python_code is evaluated using eval(...). The result is + converted to a string, and the substitution proceeds (recursive substitution is possible). In case the python code + raises an exception e, the substitution result is f'<{str(e)}' and a warning is logged. Substitution might use + the importlib module to import modules and the special function subst(template) for substituting variables. + """ class VarDict(UserDict): + """ + Internal helper class for implementing the variable substitution in nexxT. + """ + def __init__(self, variables): self._variables = variables super().__init__() @@ -19,8 +46,11 @@ def __getitem__(self, key): res = self.data[key] if res.startswith("${!") and res.endswith("}"): try: + # pylint: disable=eval-used + # eval is insecure, but nexxT is not designed to be secure against malicious input (the whole + # plugin concept is insecure either) res = str(eval(res[3:-1], {'importlib': importlib, 'subst': self._variables.subst})) - except Exception as e: + except Exception as e: # pylint: disable=broad-except logger.warning("An error occurred while substituting '%s' evaluating to python code '%s': %s", key, res[3:-1], e) res = f"<{str(e)}>" @@ -32,12 +62,17 @@ def __getitem__(self, key): raise e def getraw(self, key): + """ + get the raw, non substituted value corresponding to key + + :param key: the variable name + """ return self.data[key] def __setitem__(self, key, value): key = key.upper() if key in self._variables._readonly and self.data[key] != value: - raise RuntimeError("Trying to modify readonly variable %s." % key) + raise RuntimeError(f"Trying to modify readonly variable {key}.") self.data[key] = value self._variables.variableAddedOrChanged.emit(key, value) @@ -46,19 +81,6 @@ def __delitem__(self, key): super().__delitem__(key) self._variables.variableDeleted.emit(key) - """ - This class represents a collection of variables suitable for substitution in properties of a filter. - - Substitution is performed according to the following rules: - 1. Variables are arranged in a tree structure. During substitution the tree is searched upwards until a variable is - found. - 2. The syntax for substitution is basically the same as string.Template(value).safe_substitute. - 3. Variables are always converted to upper case strings before substituting (i.e. they are case insensitive). - 4. In case a variable is expanded to '${!python_code}', the python_code is evaluated using eval(...). The result is - converted to a string, and the substitution proceeds (recursive substitution is possible). In case the python code - raises an exception e, the substitution result is f'<{str(e)}' and a warning is logged. Substitution might use - the importlib module to import modules and the special function subst(template) for substituting variables. - """ variableAddedOrChanged = Signal(str, str) variableDeleted = Signal(str) @@ -70,12 +92,29 @@ def __init__(self, parent = None): super().__init__() def setParent(self, parent): + """ + reparent the variable class (for lookups of unknown variables) + + :param parent: the new parent + """ self._parent = parent def subst(self, content): + """ + Recursively substitute the given content. + + :param content: the string to be substituted + :return: the substituted string + """ return string.Template(content).safe_substitute(self) def setReadonly(self, readonlyvars): + """ + Set the given variables as readonly and return the old set of readonly vars. + + :param readonlyvars: an iterable containing names of readonly variables + :return: a set containing the readonly variables before the change + """ old = self._readonly self._readonly = set() for k in readonlyvars: @@ -83,12 +122,25 @@ def setReadonly(self, readonlyvars): return old def isReadonly(self, key): + """ + Return whether or not the given variable is readonly + + :param key: the variable name to be tested + """ return key.upper() in self._readonly def keys(self): + """ + :return: the variables defined in this instance. + """ return self._vars.keys() def getraw(self, key): + """ + Get the raw, non-substituted values of a variable. + + :return: a string instance + """ return self._vars.getraw(key) def __setitem__(self, key, value): @@ -98,4 +150,4 @@ def __getitem__(self, key): return self._vars[key] def __delitem__(self, key): - del self._vars[key] \ No newline at end of file + del self._vars[key] diff --git a/nexxT/core/qrc_resources.py b/nexxT/core/qrc_resources.py index 4d04c73..8db8f24 100644 --- a/nexxT/core/qrc_resources.py +++ b/nexxT/core/qrc_resources.py @@ -1,278 +1,278 @@ -# Resource object code (Python 3) -# Created by: object code -# Created by: The Resource Compiler for Qt version 6.4.0 -# WARNING! All changes made in this file will be lost! - -from PySide6 import QtCore - -qt_resource_data = b"\ -\x00\x00\x08d\ -<\ -?xml version=\x221.\ -0\x22 encoding=\x22UTF\ --8\x22 standalone=\x22\ -no\x22?>\x0a\x0a\ -\x0a\x0a \x0a \ - \x0a \x0a \x0a \x0a \ -image\ -/svg+xml\x0a \x0a \ - \x0a \x0a \x0a \x0a \x0a \ - \x0a \ - \x0a \x0a\x0a\ -\x00\x00\x05\xf5\ -\x00\ -\x00\x1f#x\xda\xedY\xdb\x8e\xdb6\x10}\xdf\xafP\ -\xb5\x0fI\xd0P\x22u\x97\xd6v\xd06\x08\x10\xa0\xe8\ -C\xbbE\x9f\xb9\x12m\x13\x91D\x83\xa2\xd7v\xbe\xbe\ -C\xeaf\xf9Rl\xb1H\x11\x146\xb0Xk\xe6p\ -\x863<<\x22wg\x1f\xf6Ui=3\xd9pQ\ -\xcfm\xe2`\xdbbu.\x0a^\xaf\xe6\xf6\x9f\x8f\x9f\ -Pb[\x8d\xa2uAKQ\xb3\xb9]\x0b\xfb\xc3\xe2\ -n\xf6\x03B\xd6/\x92Q\xc5\x0ak\xc7\xd5\xda\xfa\x5c\ -\x7fir\xbaa\xd6\xdb\xb5R\x9b\xccuw\xbb\x9d\xc3\ -;\xa3#\xe4\xca}g!\xb4\xb8\xbb\x9b5\xcf\xab;\ -\xcb\xb2 o\xdddE>\xb7\xbb\x01\x9b\xad,\x0d\xb0\ -\xc8]V\xb2\x8a\xd5\xaaq\x89C\x5c{\x84\xe7#<\ -\xd7\xd9\xf93\xcbEU\x89\xba1#\xeb\xe6\xfe\x08,\ -\x8b\xe5\x80\xd6\xb3\xd9\xf9\x06D\xd24u\xb1\xe7z\x1e\ -\x02\x04j\x0e\xb5\xa2{4\x1d\x0as\xbc4\xd4\xc3\x18\ -\xbb\xe0\x1b\x91/Ce\x0d4t\x03?\x03\xbc78\ -\x8d\xd8\xca\x9c-a\x1csj\xa6\xdc\x8f\x8f\x1f\x07'\ -\xc2N\xa1\x8a\xa30}?'Y'M\xaei\xc5\x9a\ -\x0d\xcdY\xe3\xf6v3~\xc7\x0b\xb5\x9e\xdb^\xe0\xf8\ -\xd8'\xa9WU\xc6\xbcf|\xb5V\xe7\xf6g\xcev\ -?\x8b\xfd\xdc\xc6\x16\xb6\x06\xe7\xf8\xad\x05\x8d\xac!\xc6\ -\xc0\x8b\xb9\x0du'\xedC\x97>\x1b`\xd8I='\ -\xb0\xde\x86\x05\x8d\x924\xf7\x89\xff\xde\xf20I\x11&\ -\x88\x04\xef\xcc\xa8\xbe\xf4\xac\x10\xb9\xae\x05\x08\xc7\xf6\xfb\ -G\xa7o\xe7\x10\x95\xed7B*\xb4\xe4%kq\xee\ -ZT\xcc\xddqV\xb0\x8a\xd6n\xc1\x9eY)6\x9a\ -E\xae\x09\xe1\xee\x84\xfcbZ\xd3>\xa3(p6\xf5\ -\xe5\xa8\xfbb\x03K\x15EN\x92\x00Y\xd2\x8b\x98\xc3\ -\x09f\x01\xa0Y\xc1\x96\x8d\x06\xb7\xbd\xd0O\x9em\xb9\ -\xc65T\xa6\xa7[\xe8\x06\x8f\xc0'\xda\xb4\xcbdY\ -\x1b\xba\x02J\x97B\xce\xed\xfb\xa5\xf9t\x8e'!\x0b\ -&{Wd>\x13\x97\x80\xda\xb8:\xb4\x9b\xb8\x8b\xdd\ -\xcfYG\x1d\xfc\xf8\xb2\xbfY\xd3B\xec\x80\x0b\xa7\xce\ -\xafBTzT\x10za\x90\xf8\xc1\xa9?\x07\x9e \ -p8$\xc1Q|\xe6\x85\x8c^\x1c8)\xf6\xe3\xf0\ -\xd4\x09\xab\xbc\xd5K\x84\xb65W\xb0\x99:\xfa\x1d\x0f\ -\xdfJ\xa9\x01%=0(\xdc\xfc\x22\x1d\xa8Y\x8b\xdd\ -J\xea\x06.i9tp\x18\xba\xe35\x14\x84:\xea\ -\x03k\xf1\x15D\xbf\x0b\xd24\xbc\x82\xd0;\xe1\x8a\xeb\ -p\xddU\xd1=\xaf\xf8W\x06\x13$\x86\x1f@\x83\x01\ -\xa3'\xde\x0e\xb3,u\xd0{z\x7f\xd06\xbb7\xea\ -\xba\xb4!\x08Co0\x0a\xc9W\xbc6\x0d\xf7\x1c\x1c\ -{8 '>\x98\x0f\x22Q\xec\xf8\x91\x1f\xa5=\xf9\ -\xdcs\xf6\x19{\xc5\x14-\xa8\xa2#\x15{K\xd8O\ -\x1842\xfb\xfd\xe3\xa7E\x97e\x96\xe7\xd9_\xb0\x93\ -\xfa\xa4\x96\xa5\x01\xf4Il\xa1\x81\xf6b0\xcf\x8a<\ -\x03U\xab\xa8Z\xf0\x0a\xd8\xa5\x05\xf1GP\xb1\x99;\ -:&`\xdd\x831h\x1bV\xb2V\x1e/\xbe#\x8a\ -\xbc\xe2z\x90\xfb\x87\xe2e\xf9Y'\xe9\xca=\x0a\xca\ -U\xc9\x16&g\xfb\xb5\xaf\xc2\xed\xca\xe8\x8at\x8f\xaa\ -\x9c\xb9}\x0f\xcc\xd3\xeadmK\xfa\xc4\xca\xb9\xfd\xab\ -&\xa2ENW~%\xc5vS\x89\x82uT\xb5\xc7\ -\xceN\xa8\xab$\xad\x1b\xdd\x86\xb9m\xbe\x96\xf0&}\ -;\xae\xe9{Dp\xe8\xf8\xbeO\xbcw\xfdB\xe4\x5c\ -\xe6\xe5\xd0\xa2F\x1dJH\x02\xfaWf\xf7y\xf2\xc4\ -\x9e\xe2\x87FI\xf1\x85e\xf7\xd8|\xba\xc7\x96\xff\x19\ -v\x12\xcf\xd7f\xd2\xdb\xa1{L\x96@P\x95\x05\xbd\ -\xad\xa0\xa0\x02R\xd2CV\xc3\xdb\xbe\xb7v\xca\x91\x91\ -\x0977T\xad\x03\x10\x84\xc1\xa8u \x0c\x1c\xcf\x03\ -\xea\xc5\xa3U+\x12\x89\x9d \x09\xe2\x91\xc7R\x1b\x9d\ -8\xc08\x8d\x86U\x9b)\xb6W=\x02\xb8\x92\x19\xb9\ -\x86L\xc0\x03&\x9f\x99}Z\xbc\x00a0\xdfa\xba\ -\xc0\xa8\xf2\xc1X\x9e\xa9\xe4\xb4V\x13\xdb\xcel\xf1\x89\ -\x09\x8ac*_Om\xb0[3\x82\x9d0\x81\xde\xfb\ -\xd8\xdb\xec\x1fJ^\xb3N!2\xe2xa\x0b\x5c\xd2\ -\x8a\x97\x87\xec\xcdO\x90\xaa\xb4~.i\xfe\xe5\xcd\x03\ -\xeai\x80\xda`\x1b\x96\xf3%\xcf\xe1\x98\x22\xea\x09\xf4\ -\xbd\xf5\x9bI\xfaf2aT\xf2\x15U[(\xf6R\ -9\x08\x02_v\xd4 \xa1\x92\xe7\x13\xdf\x92\x99H\xa8\ -aJ\xc1Qn\x18\xa8;\x8c($\xaa38\xd5I\ -\xf5P\x02\x80I\xa4;\x0d\xb8\x0cC\xc5\xf0\xa2,\xa6\ -\x06\xc9u\x10\xa4y\x9d\x95\x12\xa9\xa7.N\x9d\xaf\x85\ -\xec\x02\xb5L\xa44\x8055\x0f#mzb\x1es\ -\xaag\xa5\x17\x05\xba\xd9#3\xb4\xb6\x11\x0f\x8e\x14$\ -\x0e\xd2\xc1\x0a$\x0a\xb1CbL\x82x\xc2B=\x11\ -x1\x05\x83\xf1hgI\xa1\xcc\xb6J1l\xa2\x99\ -\x82\x92\xeaQa\x06E\x94BsI/\xb3=zM\ -h=\x00b{G\xf6\xcb\xb3\xbb2\xbf\xff\x96\xa8\xff\ -/^\xfe{\xce]#\xd6\xa2\x9e\xb9f)\xe1=\xa0\ -C\xdc\xb4\xe6\xa65\xe3n\x86]\x1b\xc5q\x1a\xf9\xc7\ -RC\x88\xe7\xa4Q\xe8\x91KR\x93\xbcFK\xa2\xa9\ -\x96\x9cg\xbf\x92\xff&%\xdf\x85\x94\xb0\x9b\x94\xdc\xa4\ -\xe4\x9a\x94\x10\x12\xc0\xf1\xdd\x8f\xc2c)Aa\x02'\ -b\x12\x90\xe8\x82\x96\x1c\x8d\x1fn\x10\xc3\xf9\x05\xe5p\ -\xef\x85\x1a\xcd\x8d\x9e$^\x12{/\x81\xeb\x13\x0a\x5c\ -#\xd2 H\xc2\xe8\x1fNE\xaf;\x14\xf9x*d\ -\xe7\xb5_\xab\xfe\xa6d\xdf\x85\x92\xedoJvS\xb2\ -\xab\x17\xb00\x86s\x09\x09#o\x22e\xfa\xe2\x93\x10\ -?\x09/IY\xf4Bm\x82\x8c>\x09\x83\x10\x87/\ -\xd3>\x04k\x8e\xa3\x00\xa7\xe4\x92\x98\xc1\xb8\x12nx\ -\xe4UZ\x16\x9c\x5c\xf0\xce\xab\xbfV\xffM\xccnb\ -v\x13\xb3o&f\xdd\x1f2_\x7f\xc3\xf3\x03\xdfO\ -&7<\xd8\xca\xbew\xfc\xb7\x9dQ\xca\x02\xfc\x1a1\ -I\xceox\xd3\xecW\xf2\xdf\xa4\xe4\x1bK\xc9U\xf5\ -x\x0a\x0a\ +\x0a\x0a \x0a \ + \x0a \x0a \x0a \x0a \ +image\ +/svg+xml\x0a \x0a \ + \x0a \x0a \x0a \x0a \x0a \ + \x0a \ + \x0a \x0a\x0a\ +\x00\x00\x05\xf5\ +\x00\ +\x00\x1f#x\xda\xedY\xdb\x8e\xdb6\x10}\xdf\xafP\ +\xb5\x0fI\xd0P\x22u\x97\xd6v\xd06\x08\x10\xa0\xe8\ +C\xbbE\x9f\xb9\x12m\x13\x91D\x83\xa2\xd7v\xbe\xbe\ +C\xeaf\xf9Rl\xb1H\x11\x146\xb0Xk\xe6p\ +\x863<<\x22wg\x1f\xf6Ui=3\xd9pQ\ +\xcfm\xe2`\xdbbu.\x0a^\xaf\xe6\xf6\x9f\x8f\x9f\ +Pb[\x8d\xa2uAKQ\xb3\xb9]\x0b\xfb\xc3\xe2\ +n\xf6\x03B\xd6/\x92Q\xc5\x0ak\xc7\xd5\xda\xfa\x5c\ +\x7fir\xbaa\xd6\xdb\xb5R\x9b\xccuw\xbb\x9d\xc3\ +;\xa3#\xe4\xca}g!\xb4\xb8\xbb\x9b5\xcf\xab;\ +\xcb\xb2 o\xdddE>\xb7\xbb\x01\x9b\xad,\x0d\xb0\ +\xc8]V\xb2\x8a\xd5\xaaq\x89C\x5c{\x84\xe7#<\ +\xd7\xd9\xf93\xcbEU\x89\xba1#\xeb\xe6\xfe\x08,\ +\x8b\xe5\x80\xd6\xb3\xd9\xf9\x06D\xd24u\xb1\xe7z\x1e\ +\x02\x04j\x0e\xb5\xa2{4\x1d\x0as\xbc4\xd4\xc3\x18\ +\xbb\xe0\x1b\x91/Ce\x0d4t\x03?\x03\xbc78\ +\x8d\xd8\xca\x9c-a\x1csj\xa6\xdc\x8f\x8f\x1f\x07'\ +\xc2N\xa1\x8a\xa30}?'Y'M\xaei\xc5\x9a\ +\x0d\xcdY\xe3\xf6v3~\xc7\x0b\xb5\x9e\xdb^\xe0\xf8\ +\xd8'\xa9WU\xc6\xbcf|\xb5V\xe7\xf6g\xcev\ +?\x8b\xfd\xdc\xc6\x16\xb6\x06\xe7\xf8\xad\x05\x8d\xac!\xc6\ +\xc0\x8b\xb9\x0du'\xedC\x97>\x1b`\xd8I='\ +\xb0\xde\x86\x05\x8d\x924\xf7\x89\xff\xde\xf20I\x11&\ +\x88\x04\xef\xcc\xa8\xbe\xf4\xac\x10\xb9\xae\x05\x08\xc7\xf6\xfb\ +G\xa7o\xe7\x10\x95\xed7B*\xb4\xe4%kq\xee\ +ZT\xcc\xddqV\xb0\x8a\xd6n\xc1\x9eY)6\x9a\ +E\xae\x09\xe1\xee\x84\xfcbZ\xd3>\xa3(p6\xf5\ +\xe5\xa8\xfbb\x03K\x15EN\x92\x00Y\xd2\x8b\x98\xc3\ +\x09f\x01\xa0Y\xc1\x96\x8d\x06\xb7\xbd\xd0O\x9em\xb9\ +\xc65T\xa6\xa7[\xe8\x06\x8f\xc0'\xda\xb4\xcbdY\ +\x1b\xba\x02J\x97B\xce\xed\xfb\xa5\xf9t\x8e'!\x0b\ +&{Wd>\x13\x97\x80\xda\xb8:\xb4\x9b\xb8\x8b\xdd\ +\xcfYG\x1d\xfc\xf8\xb2\xbfY\xd3B\xec\x80\x0b\xa7\xce\ +\xafBTzT\x10za\x90\xf8\xc1\xa9?\x07\x9e \ +p8$\xc1Q|\xe6\x85\x8c^\x1c8)\xf6\xe3\xf0\ +\xd4\x09\xab\xbc\xd5K\x84\xb65W\xb0\x99:\xfa\x1d\x0f\ +\xdfJ\xa9\x01%=0(\xdc\xfc\x22\x1d\xa8Y\x8b\xdd\ +J\xea\x06.i9tp\x18\xba\xe35\x14\x84:\xea\ +\x03k\xf1\x15D\xbf\x0b\xd24\xbc\x82\xd0;\xe1\x8a\xeb\ +p\xddU\xd1=\xaf\xf8W\x06\x13$\x86\x1f@\x83\x01\ +\xa3'\xde\x0e\xb3,u\xd0{z\x7f\xd06\xbb7\xea\ +\xba\xb4!\x08Co0\x0a\xc9W\xbc6\x0d\xf7\x1c\x1c\ +{8 '>\x98\x0f\x22Q\xec\xf8\x91\x1f\xa5=\xf9\ +\xdcs\xf6\x19{\xc5\x14-\xa8\xa2#\x15{K\xd8O\ +\x1842\xfb\xfd\xe3\xa7E\x97e\x96\xe7\xd9_\xb0\x93\ +\xfa\xa4\x96\xa5\x01\xf4Il\xa1\x81\xf6b0\xcf\x8a<\ +\x03U\xab\xa8Z\xf0\x0a\xd8\xa5\x05\xf1GP\xb1\x99;\ +:&`\xdd\x831h\x1bV\xb2V\x1e/\xbe#\x8a\ +\xbc\xe2z\x90\xfb\x87\xe2e\xf9Y'\xe9\xca=\x0a\xca\ +U\xc9\x16&g\xfb\xb5\xaf\xc2\xed\xca\xe8\x8at\x8f\xaa\ +\x9c\xb9}\x0f\xcc\xd3\xeadmK\xfa\xc4\xca\xb9\xfd\xab\ +&\xa2ENW~%\xc5vS\x89\x82uT\xb5\xc7\ +\xceN\xa8\xab$\xad\x1b\xdd\x86\xb9m\xbe\x96\xf0&}\ +;\xae\xe9{Dp\xe8\xf8\xbeO\xbcw\xfdB\xe4\x5c\ +\xe6\xe5\xd0\xa2F\x1dJH\x02\xfaWf\xf7y\xf2\xc4\ +\x9e\xe2\x87FI\xf1\x85e\xf7\xd8|\xba\xc7\x96\xff\x19\ +v\x12\xcf\xd7f\xd2\xdb\xa1{L\x96@P\x95\x05\xbd\ +\xad\xa0\xa0\x02R\xd2CV\xc3\xdb\xbe\xb7v\xca\x91\x91\ +\x0977T\xad\x03\x10\x84\xc1\xa8u \x0c\x1c\xcf\x03\ +\xea\xc5\xa3U+\x12\x89\x9d \x09\xe2\x91\xc7R\x1b\x9d\ +8\xc08\x8d\x86U\x9b)\xb6W=\x02\xb8\x92\x19\xb9\ +\x86L\xc0\x03&\x9f\x99}Z\xbc\x00a0\xdfa\xba\ +\xc0\xa8\xf2\xc1X\x9e\xa9\xe4\xb4V\x13\xdb\xcel\xf1\x89\ +\x09\x8ac*_Om\xb0[3\x82\x9d0\x81\xde\xfb\ +\xd8\xdb\xec\x1fJ^\xb3N!2\xe2xa\x0b\x5c\xd2\ +\x8a\x97\x87\xec\xcdO\x90\xaa\xb4~.i\xfe\xe5\xcd\x03\ +\xeai\x80\xda`\x1b\x96\xf3%\xcf\xe1\x98\x22\xea\x09\xf4\ +\xbd\xf5\x9bI\xfaf2aT\xf2\x15U[(\xf6R\ +9\x08\x02_v\xd4 \xa1\x92\xe7\x13\xdf\x92\x99H\xa8\ +aJ\xc1Qn\x18\xa8;\x8c($\xaa38\xd5I\ +\xf5P\x02\x80I\xa4;\x0d\xb8\x0cC\xc5\xf0\xa2,\xa6\ +\x06\xc9u\x10\xa4y\x9d\x95\x12\xa9\xa7.N\x9d\xaf\x85\ +\xec\x02\xb5L\xa44\x8055\x0f#mzb\x1es\ +\xaag\xa5\x17\x05\xba\xd9#3\xb4\xb6\x11\x0f\x8e\x14$\ +\x0e\xd2\xc1\x0a$\x0a\xb1CbL\x82x\xc2B=\x11\ +x1\x05\x83\xf1hgI\xa1\xcc\xb6J1l\xa2\x99\ +\x82\x92\xeaQa\x06E\x94BsI/\xb3=zM\ +h=\x00b{G\xf6\xcb\xb3\xbb2\xbf\xff\x96\xa8\xff\ +/^\xfe{\xce]#\xd6\xa2\x9e\xb9f)\xe1=\xa0\ +C\xdc\xb4\xe6\xa65\xe3n\x86]\x1b\xc5q\x1a\xf9\xc7\ +RC\x88\xe7\xa4Q\xe8\x91KR\x93\xbcFK\xa2\xa9\ +\x96\x9cg\xbf\x92\xff&%\xdf\x85\x94\xb0\x9b\x94\xdc\xa4\ +\xe4\x9a\x94\x10\x12\xc0\xf1\xdd\x8f\xc2c)Aa\x02'\ +b\x12\x90\xe8\x82\x96\x1c\x8d\x1fn\x10\xc3\xf9\x05\xe5p\ +\xef\x85\x1a\xcd\x8d\x9e$^\x12{/\x81\xeb\x13\x0a\x5c\ +#\xd2 H\xc2\xe8\x1fNE\xaf;\x14\xf9x*d\ +\xe7\xb5_\xab\xfe\xa6d\xdf\x85\x92\xedoJvS\xb2\ +\xab\x17\xb00\x86s\x09\x09#o\x22e\xfa\xe2\x93\x10\ +?\x09/IY\xf4Bm\x82\x8c>\x09\x83\x10\x87/\ +\xd3>\x04k\x8e\xa3\x00\xa7\xe4\x92\x98\xc1\xb8\x12nx\ +\xe4UZ\x16\x9c\x5c\xf0\xce\xab\xbfV\xffM\xccnb\ +v\x13\xb3o&f\xdd\x1f2_\x7f\xc3\xf3\x03\xdfO\ +&7<\xd8\xca\xbew\xfc\xb7\x9dQ\xca\x02\xfc\x1a1\ +I\xceox\xd3\xecW\xf2\xdf\xa4\xe4\x1bK\xc9U\xf5\ +x Date: Sat, 1 Jul 2023 19:39:07 +0200 Subject: [PATCH 03/17] use the qtreeview native support for check boxes --- nexxT/services/SrvConfiguration.py | 12 +++++++++++- nexxT/services/gui/PropertyDelegate.py | 13 ------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/nexxT/services/SrvConfiguration.py b/nexxT/services/SrvConfiguration.py index 6e5e1a7..62ba408 100644 --- a/nexxT/services/SrvConfiguration.py +++ b/nexxT/services/SrvConfiguration.py @@ -585,6 +585,13 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma # this might happen when a variable is already deleted and the model updates itself return "" logger.warning("Unknown item %s", repr(item)) + if role == Qt.CheckStateRole: + if isinstance(item, self.PropertyContent) and index.column() == 2: + p = item.property.getPropertyDetails(item.name) + if p.useEnvironment: + return Qt.Checked + else: + return Qt.Unchecked if role == Qt.DecorationRole: if index.column() != 0: return None @@ -649,7 +656,10 @@ def flags(self, index): # pylint: disable=too-many-return-statements,too-many-br if isinstance(item, self.PropertyContent): if index.column() == 0: return Qt.ItemIsEnabled - return Qt.ItemIsEnabled | Qt.ItemIsEditable + if index.column() == 1: + return Qt.ItemIsEnabled | Qt.ItemIsEditable + if index.column() == 2: + return Qt.ItemIsEnabled | Qt.ItemIsUserCheckable if isinstance(item, self.VariableContent): if item.variables.isReadonly(item.name): return Qt.ItemFlag.NoItemFlags diff --git a/nexxT/services/gui/PropertyDelegate.py b/nexxT/services/gui/PropertyDelegate.py index 4d17840..4a40d03 100644 --- a/nexxT/services/gui/PropertyDelegate.py +++ b/nexxT/services/gui/PropertyDelegate.py @@ -51,9 +51,6 @@ def createEditor(self, parent, option, index): else: res = QLineEdit(parent) return res - if index.column() == 2: - res = QCheckBox(parent) - return res return super().createEditor(parent, option, index) def setEditorData(self, editor, index): @@ -74,9 +71,6 @@ def setEditorData(self, editor, index): else: editor.setText(d.property.getProperty(d.name, subst=False)) return None - if index.column() == 2: - editor.setChecked(p.useEnvironment) - return None return super().setEditorData(editor, index) def setModelData(self, editor, model, index): @@ -101,11 +95,4 @@ def setModelData(self, editor, model, index): value = editor.text() model.setData(index, value, Qt.EditRole) return None - if index.column() == 2: - if editor.isChecked() and not p.useEnvironment: - model.setData(index, True, Qt.EditRole) - #model.setData(model.index(index.row(), index.column(), index.parent()), str(model)) - elif not editor.isChecked() and p.useEnvironment: - model.setData(index, False, Qt.EditRole) - return None return super().setModelData(editor, model, index) From 2533947a7a8ea79e35818009d680262176cd96ba Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 2 Jul 2023 13:07:10 +0200 Subject: [PATCH 04/17] the composite nodes are now getting specific variable instances per filter instance fixed dirty setting of configuration when changing variables --- nexxT/core/ActiveApplication.py | 10 ++- nexxT/core/CompositeFilter.py | 1 + nexxT/core/ConfigFileSchema.json | 15 ++++ nexxT/core/Configuration.py | 15 ++++ nexxT/core/FilterMockup.py | 5 +- nexxT/core/Graph.py | 2 + nexxT/core/PropertyCollectionImpl.py | 119 ++++++++++++++++++++++++-- nexxT/core/SubConfiguration.py | 9 ++ nexxT/core/Thread.py | 8 +- nexxT/core/Variables.py | 1 - nexxT/examples/framework/example.json | 17 +++- nexxT/services/SrvConfiguration.py | 2 + 12 files changed, 183 insertions(+), 21 deletions(-) diff --git a/nexxT/core/ActiveApplication.py b/nexxT/core/ActiveApplication.py index c5478c1..542f84c 100644 --- a/nexxT/core/ActiveApplication.py +++ b/nexxT/core/ActiveApplication.py @@ -15,6 +15,7 @@ from nexxT.core.CompositeFilter import CompositeFilter from nexxT.core.Utils import Barrier, assertMainThread, mainThread, MethodInvoker from nexxT.core.Thread import NexTThread +from nexxT.core.PropertyCollectionImpl import PropertyCollectionProxy logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -58,7 +59,7 @@ def getApplication(self): """ return self._graph.getSubConfig() - def _traverseAndSetup(self, graph, namePrefix=""): + def _traverseAndSetup(self, graph, namePrefix="", variables=None): """ Recursively create threads and add the filter mockups to them """ @@ -68,19 +69,22 @@ def _traverseAndSetup(self, graph, namePrefix=""): if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): with mockup.createFilter() as cf: self._composite2graphs[filtername] = cf.getPlugin().getGraph() - self._traverseAndSetup(cf.getPlugin().getGraph(), filtername) + compositeVars = mockup.getPropertyCollectionImpl().getVariables() + self._traverseAndSetup(cf.getPlugin().getGraph(), filtername, compositeVars) elif issubclass(mockup.getPluginClass(), CompositeFilter.CompositeInputNode): pass elif issubclass(mockup.getPluginClass(), CompositeFilter.CompositeOutputNode): pass else: props = mockup.getPropertyCollectionImpl() + if variables is not None: + props = PropertyCollectionProxy(props, variables) nexTprops = props.getChildCollection("_nexxT") threadName = nexTprops.getProperty("thread") if not threadName in self._threads: # create threads as needed self._threads[threadName] = NexTThread(threadName) - self._threads[threadName].addMockup(filtername, mockup) + self._threads[threadName].addMockup(filtername, mockup, props) self._filters2threads[filtername] = threadName def __del__(self): diff --git a/nexxT/core/CompositeFilter.py b/nexxT/core/CompositeFilter.py index d55f5ef..c0258f2 100644 --- a/nexxT/core/CompositeFilter.py +++ b/nexxT/core/CompositeFilter.py @@ -80,6 +80,7 @@ def __init__(self, name, configuration): def compositeNode(self, env): """ Factory function for creating a dummy filter instance (this one will never get active). + :param env: the FilterEnvironment instance :return: a Filter instance """ diff --git a/nexxT/core/ConfigFileSchema.json b/nexxT/core/ConfigFileSchema.json index f0a24df..4818be0 100644 --- a/nexxT/core/ConfigFileSchema.json +++ b/nexxT/core/ConfigFileSchema.json @@ -38,6 +38,15 @@ } } }, + "variables": { + "type": "object", + "propertyNames": {"$ref": "#/definitions/identifier"}, + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, "sub_graph": { "description": "sub-graph definition as used by applications and composite filters.", "type": "object", @@ -93,6 +102,9 @@ "properties": { "$ref": "#/definitions/propertySection", "default": {} + }, + "variables": { + "$ref": "#/definitions/variables" } } } @@ -123,6 +135,9 @@ "$ref": "#/definitions/sub_graph" } }, + "variables": { + "$ref": "#/definitions/variables" + }, "_guiState": { "$ref": "#/definitions/propertySection", "default": {} diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index dcabccf..cbd8f49 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -62,6 +62,8 @@ def _defaultRootPropColl(self): theVars["NEXXT_PLATFORM"] = "${!importlib.import_module('nexxT.core.Utils').nexxtPlatform()}" theVars["NEXXT_VARIANT"] = "${!importlib.import_module('os').environ.get('NEXXT_VARIANT', 'release')}" theVars.setReadonly({"CFG_DIR", "NEXXT_PLATFORM", "NEXXT_VARIANT", "CFGFILE"}) + theVars.variableAddedOrChanged.connect(lambda *args: self.setDirty()) + theVars.variableDeleted.connect(lambda *args: self.setDirty()) return res def __init__(self): @@ -154,6 +156,13 @@ def compositeLookup(name): finally: recursiveset.remove(name) + variables = self._propertyCollection.getVariables() + for k in variables.keys(): + if not variables.isReadonly(k): + del variables[k] + if "variables" in cfg: + for k in cfg["variables"]: + variables[k] = cfg["variables"][k] for cfg_cf in cfg["composite_filters"]: compositeLookup(cfg_cf["name"]) for cfg_app in cfg["applications"]: @@ -184,6 +193,12 @@ def save(self, file=None): except KeyError: cfg["CFGFILE"] = None cfg["_guiState"] = self._guiState.saveDict() + variables = self._propertyCollection.getVariables() + if any(not variables.isReadonly(k) for k in variables.keys()): + cfg["variables"] = { + k: variables.getraw(k) + for k in variables.keys() if not variables.isReadonly(k) + } cfg["composite_filters"] = [cf.save() for cf in self._compositeFilters] cfg["applications"] = [app.save() for app in self._applications] self.configNameChanged.emit(cfg["CFGFILE"]) diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index 91bfd59..2a9a487 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -98,14 +98,15 @@ def _createFilterAndUpdate(self): if self._pluginClass is cnexxT.QSharedPointer_nexxT_Filter: self._pluginClass = tempEnv.getPlugin().data().__class__ - def createFilter(self): + def createFilter(self, propColl=None): """ Creates the filter for real usage. State is CONSTRUCTED. This function is thread safe and can be called from multiple threads. :return: None """ # called from threads - res = FilterEnvironment(self._library, self._factoryFunction, self._propertyCollectionImpl) + res = FilterEnvironment(self._library, self._factoryFunction, + self._propertyCollectionImpl if propColl is None else propColl) with QMutexLocker(self._portMutex): for p in self._ports: if p.dynamic(): diff --git a/nexxT/core/Graph.py b/nexxT/core/Graph.py index 0a92c9a..b2feaa4 100644 --- a/nexxT/core/Graph.py +++ b/nexxT/core/Graph.py @@ -97,6 +97,8 @@ def addNode(self, library, factoryFunction, suggestedName=None, except PropertyCollectionChildNotFound: propColl = PropertyCollectionImpl(name, self._properties) propColl.propertyChanged.connect(self.setDirty) + propColl.getVariables().variableAddedOrChanged.connect(self.setDirty) + propColl.getVariables().variableDeleted.connect(self.setDirty) filterMockup = FilterMockup(library, factoryFunction, propColl, self) self._filters[name] = filterMockup for din in dynamicInputPorts: diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index e1d7c9c..91acd47 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -108,7 +108,7 @@ def getVariables(self): """ return self._vars - def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None, variables=None): """ Return the value of the given property, creating a new property if it doesn't exist. :param name: the name of the property @@ -158,7 +158,9 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle if not p.useEnvironment: p.value = p.handler.validate(p.handler.fromConfig(p.value)) else: - p.handler.validate(self._vars.subst(p.value)) + if variables is None: + variables = self._vars + p.handler.validate(variables.subst(p.value)) except Exception as e: raise PropertyParsingError( f"Error parsing property {name} from {repr(l)} (original exception: {str(e)})") from e @@ -179,7 +181,7 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle return p.value @Slot(str) - def getProperty(self, name, subst=True): + def getProperty(self, name, subst=True, variables=None): self._accessed = True with QMutexLocker(self._propertyMutex): if name not in self._properties: @@ -187,7 +189,9 @@ def getProperty(self, name, subst=True): p = self._properties[name] p.used = True if p.useEnvironment and subst: - return p.handler.validate(self._vars.subst(p.value)) + if variables is None: + variables = self._vars + return p.handler.validate(variables.subst(p.value)) return p.value def getPropertyDetails(self, name): @@ -345,18 +349,119 @@ def deleteChild(self, name): if nexxT.shiboken.isValid(cc): # pylint: disable=no-member nexxT.shiboken.delete(cc) # pylint: disable=no-member - def evalpath(self, path): + def evalpath(self, path, variables=None): """ Evaluates the string path. If it is an absolute path it is unchanged, otherwise it is converted to an absolute path relative to the config file path. :param path: a string :return: absolute path as string """ - spath = self._vars.subst(path) + if variables is None: + variables = self._vars + spath = variables.subst(path) if spath != path or not Path(spath).is_absolute(): logger.warning("Deprecated: Implicit substitution or relative paths to the config file. Consider to use " "explicit variable substitution with ${CFG_DIR} to reference the directory of the config " "file instead. Found while evaluating %s.", path) if not Path(spath).is_absolute(): - spath = str((Path(self._vars.subst("$CFG_DIR")) / spath).absolute()) + spath = str((Path(variables.subst("$CFG_DIR")) / spath).absolute()) return spath + +class PropertyCollectionProxy(PropertyCollection): + """ + This class proxies to a PropertyCollection object but uses a different instance of variables + """ + propertyChanged = Signal(object, str) + + def __init__(self, proxiedPropColl, variables): + PropertyCollection.__init__(self) + self._proxiedPropColl = proxiedPropColl + self._vars = variables + assertMainThread() + self._propertyMutex = QRecursiveMutex() + proxiedPropColl.propertyChanged.connect(self._propertyChanged) + self.setObjectName(self._proxiedPropColl.objectName()) + + def _propertyChanged(self, propColl, name): + self.propertyChanged.emit(self, name) + + def getChildCollection(self, name): + """ + Return child property collection with given name + :param name: the name of the child + :return: PropertyCollection instance + """ + return self._proxiedPropColl.getChildCollection(name) + + def getVariables(self): + """ + Return the associated variables instance. + """ + return self._vars + + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): + """ + Return the value of the given property, creating a new property if it doesn't exist. + :param name: the name of the property + :param defaultVal: the default value of the property. Note that this value will be used to determine the + property's type. Currently supported types are string, int and float + :param helpstr: a help string for the user + :param options: a dict mapping string to qvariant (common options: min, max, enum) + :param propertyHandler: a PropertyHandler instance, or None for automatic choice according to defaultVal + :return: the current value of this property + """ + return self._proxiedPropColl.defineProperty(name, defaultVal, helpstr, options, propertyHandler, + variables=self._vars) + + @Slot(str) + def getProperty(self, name, subst=True): + """ + See PropertyCollectionImpl.getProperty for details + """ + return self._proxiedPropColl.getProperty(name, subst, variables=self._vars) + + def getPropertyDetails(self, name): + """ + returns the property details of the property identified by name. + :param name: the property name + :return: a Property instance + """ + return self._proxiedPropColl.getPropertyDetails(self, name) + + def getAllPropertyNames(self): + """ + Query all property names handled in this collection + :return: list of strings + """ + return self._proxiedPropColl.getAllPropertyNames() + + @Slot(str, str) + def setProperty(self, name, value): + """ + Set the value of a named property. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._proxiedPropColl.setProperty(name, value) + + @Slot(str, str) + def setVarProperty(self, name, value): + """ + Set the value of a named property using an variable substitution. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._proxiedPropColl.setVarProperty(name, value) + + def evalpath(self, path): + """ + Evaluates the string path. If it is an absolute path it is unchanged, otherwise it is converted + to an absolute path relative to the config file path. + :param path: a string + :return: absolute path as string + """ + return self._proxiedPropColl.evalpath(path, variables=self._vars) diff --git a/nexxT/core/SubConfiguration.py b/nexxT/core/SubConfiguration.py index 4ff65f2..3b1dfc0 100644 --- a/nexxT/core/SubConfiguration.py +++ b/nexxT/core/SubConfiguration.py @@ -160,6 +160,10 @@ def load(self, cfg, compositeLookup): raise NexTInternalError("addNode(...) has set unexpected name for node.") # make sure that the filter is instantiated and the port information is updated immediately self._graph.getMockup(n["name"]).createFilterAndUpdate() + if "variables" in n: + variables = self._graph.getMockup(n["name"]).getPropertyCollectionImpl().getVariables() + for k in n["variables"]: + variables[k] = n["variables"][k] for c in cfg["connections"]: contuple = self.connectionStringToTuple(c) self._graph.addConnection(*contuple[:-1]) @@ -223,6 +227,11 @@ def adaptLibAndFactory(lib, factory): pass except PropertyCollectionPropertyNotFound: pass + variables = p.getVariables() + if len(variables.keys()) > 0: + ncfg["variables"] = {} + for k in variables.keys(): + ncfg["variables"][k] = variables.getraw(k) ncfg["properties"] = p.saveDict() cfg["nodes"].append(ncfg) cfg["connections"] = [] diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index 3e196ce..a4ab6b6 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -119,7 +119,7 @@ def cleanup(self): self._mockups.clear() logger.internal("Thread cleanup done") - def addMockup(self, name, mockup): + def addMockup(self, name, mockup, propColl): """ Add a FilterMockup instance by name. :param name: name of the filter @@ -128,7 +128,7 @@ def addMockup(self, name, mockup): """ if name in self._mockups: raise NodeExistsError(name) - self._mockups[name] = mockup + self._mockups[name] = (mockup, propColl) def getFilter(self, name): """ @@ -179,10 +179,10 @@ def performOperation(self, operation, barrier): # wait for all threads barrier.wait() # perform operation for all filters - for name, mockup in self._mockups.items(): + for name, (mockup, propColl) in self._mockups.items(): try: if operation == "create": - res = mockup.createFilter() + res = mockup.createFilter(propColl) res.setParent(self) self._filters[name] = res self._filter2name[res] = name diff --git a/nexxT/core/Variables.py b/nexxT/core/Variables.py index 76f04df..7ccd9dc 100644 --- a/nexxT/core/Variables.py +++ b/nexxT/core/Variables.py @@ -81,7 +81,6 @@ def __delitem__(self, key): super().__delitem__(key) self._variables.variableDeleted.emit(key) - variableAddedOrChanged = Signal(str, str) variableDeleted = Signal(str) diff --git a/nexxT/examples/framework/example.json b/nexxT/examples/framework/example.json index 35bb3c4..fce57fd 100644 --- a/nexxT/examples/framework/example.json +++ b/nexxT/examples/framework/example.json @@ -2,6 +2,9 @@ "_guiState": { "PlaybackControl_showAllFiles": 0 }, + "variables": { + "SRC": "fail" + }, "composite_filters": [ { "name": "visualization", @@ -44,8 +47,8 @@ "thread": "main", "properties": { "caption": { - "subst": false, - "value": "Processed" + "subst": true, + "value": "Processed - $SRC" }, "scale": { "subst": false, @@ -66,8 +69,8 @@ "thread": "main", "properties": { "caption": { - "subst": false, - "value": "Original" + "subst": true, + "value": "Original - $SRC" }, "scale": { "subst": false, @@ -201,6 +204,9 @@ "dynamicOutputPorts": [], "staticOutputPorts": [], "thread": "main", + "variables": { + "SRC": "live" + }, "properties": {} } ], @@ -256,6 +262,9 @@ "dynamicOutputPorts": [], "staticOutputPorts": [], "thread": "main", + "variables": { + "SRC": "sim" + }, "properties": {} }, { diff --git a/nexxT/services/SrvConfiguration.py b/nexxT/services/SrvConfiguration.py index 62ba408..cd08973 100644 --- a/nexxT/services/SrvConfiguration.py +++ b/nexxT/services/SrvConfiguration.py @@ -724,6 +724,8 @@ def setData(self, index, value, role):# pylint: disable=too-many-return-statemen return False elif index.column() == 2: p = item.property.getPropertyDetails(item.name) + if role == Qt.CheckStateRole: + value = False if Qt.CheckState(value) == Qt.Unchecked else True if value and not p.useEnvironment: item.property.setVarProperty(item.name, str(item.property.getProperty(item.name))) elif not value and p.useEnvironment: From 2139832b1e2b2a82297408d5e5e7d0138d44ece1 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 2 Jul 2023 13:07:10 +0200 Subject: [PATCH 05/17] the composite nodes are now getting specific variable instances per filter instance fixed dirty setting of configuration when changing variables add test case for ensuring the correct behaviour with nested composite filters --- nexxT/core/ActiveApplication.py | 19 +- nexxT/core/CompositeFilter.py | 1 + nexxT/core/ConfigFileSchema.json | 15 + nexxT/core/Configuration.py | 15 + nexxT/core/FilterMockup.py | 5 +- nexxT/core/Graph.py | 5 + nexxT/core/PropertyCollectionImpl.py | 120 ++++++- nexxT/core/SubConfiguration.py | 9 + nexxT/core/Thread.py | 8 +- nexxT/core/Variables.py | 16 +- nexxT/examples/framework/example.json | 17 +- nexxT/services/SrvConfiguration.py | 2 + nexxT/services/gui/MainWindow.py | 2 + nexxT/tests/core/test_ActiveApplication.py | 14 + nexxT/tests/integration/composite.json | 367 +++++++++++++++++++++ nexxT/tests/integration/test_gui.py | 264 ++++++++++++++- 16 files changed, 856 insertions(+), 23 deletions(-) create mode 100644 nexxT/tests/integration/composite.json diff --git a/nexxT/core/ActiveApplication.py b/nexxT/core/ActiveApplication.py index c5478c1..861dbb3 100644 --- a/nexxT/core/ActiveApplication.py +++ b/nexxT/core/ActiveApplication.py @@ -15,6 +15,8 @@ from nexxT.core.CompositeFilter import CompositeFilter from nexxT.core.Utils import Barrier, assertMainThread, mainThread, MethodInvoker from nexxT.core.Thread import NexTThread +from nexxT.core.PropertyCollectionImpl import PropertyCollectionProxy +from nexxT.core.Variables import Variables logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -58,29 +60,40 @@ def getApplication(self): """ return self._graph.getSubConfig() - def _traverseAndSetup(self, graph, namePrefix=""): + def _traverseAndSetup(self, graph, namePrefix="", variables=None): """ Recursively create threads and add the filter mockups to them """ + if variables is None: + variables = graph.getSubConfig().getConfiguration().propertyCollection().getVariables() for basename in graph.allNodes(): filtername = namePrefix + "/" + basename mockup = graph.getMockup(basename) if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): with mockup.createFilter() as cf: self._composite2graphs[filtername] = cf.getPlugin().getGraph() - self._traverseAndSetup(cf.getPlugin().getGraph(), filtername) + compositeVars = mockup.getPropertyCollectionImpl().getVariables().copyAndReparent(variables) + self._traverseAndSetup(cf.getPlugin().getGraph(), filtername, compositeVars) elif issubclass(mockup.getPluginClass(), CompositeFilter.CompositeInputNode): pass elif issubclass(mockup.getPluginClass(), CompositeFilter.CompositeOutputNode): pass else: props = mockup.getPropertyCollectionImpl() + filterVars = Variables(parent=variables if variables is not None else props.getVariables()) + filterVars.setObjectName("proxy:" + filtername) + filterVars["COMPOSITENAME"] = namePrefix if namePrefix != "" else "" + filterVars["FILTERNAME"] = basename + filterVars["FULLQUALIFIEDFILTERNAME"] = filtername + filterVars["APPNAME"] = self._graph.getSubConfig().getName() + filterVars.setReadonly(["COMPOSITENAME", "FILTERNAME", "FULLQUALIFIEDFILTERNAME", "APPNAME"]) + props = PropertyCollectionProxy(props, filterVars) nexTprops = props.getChildCollection("_nexxT") threadName = nexTprops.getProperty("thread") if not threadName in self._threads: # create threads as needed self._threads[threadName] = NexTThread(threadName) - self._threads[threadName].addMockup(filtername, mockup) + self._threads[threadName].addMockup(filtername, mockup, props) self._filters2threads[filtername] = threadName def __del__(self): diff --git a/nexxT/core/CompositeFilter.py b/nexxT/core/CompositeFilter.py index d55f5ef..c0258f2 100644 --- a/nexxT/core/CompositeFilter.py +++ b/nexxT/core/CompositeFilter.py @@ -80,6 +80,7 @@ def __init__(self, name, configuration): def compositeNode(self, env): """ Factory function for creating a dummy filter instance (this one will never get active). + :param env: the FilterEnvironment instance :return: a Filter instance """ diff --git a/nexxT/core/ConfigFileSchema.json b/nexxT/core/ConfigFileSchema.json index f0a24df..4818be0 100644 --- a/nexxT/core/ConfigFileSchema.json +++ b/nexxT/core/ConfigFileSchema.json @@ -38,6 +38,15 @@ } } }, + "variables": { + "type": "object", + "propertyNames": {"$ref": "#/definitions/identifier"}, + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, "sub_graph": { "description": "sub-graph definition as used by applications and composite filters.", "type": "object", @@ -93,6 +102,9 @@ "properties": { "$ref": "#/definitions/propertySection", "default": {} + }, + "variables": { + "$ref": "#/definitions/variables" } } } @@ -123,6 +135,9 @@ "$ref": "#/definitions/sub_graph" } }, + "variables": { + "$ref": "#/definitions/variables" + }, "_guiState": { "$ref": "#/definitions/propertySection", "default": {} diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index dcabccf..cbd8f49 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -62,6 +62,8 @@ def _defaultRootPropColl(self): theVars["NEXXT_PLATFORM"] = "${!importlib.import_module('nexxT.core.Utils').nexxtPlatform()}" theVars["NEXXT_VARIANT"] = "${!importlib.import_module('os').environ.get('NEXXT_VARIANT', 'release')}" theVars.setReadonly({"CFG_DIR", "NEXXT_PLATFORM", "NEXXT_VARIANT", "CFGFILE"}) + theVars.variableAddedOrChanged.connect(lambda *args: self.setDirty()) + theVars.variableDeleted.connect(lambda *args: self.setDirty()) return res def __init__(self): @@ -154,6 +156,13 @@ def compositeLookup(name): finally: recursiveset.remove(name) + variables = self._propertyCollection.getVariables() + for k in variables.keys(): + if not variables.isReadonly(k): + del variables[k] + if "variables" in cfg: + for k in cfg["variables"]: + variables[k] = cfg["variables"][k] for cfg_cf in cfg["composite_filters"]: compositeLookup(cfg_cf["name"]) for cfg_app in cfg["applications"]: @@ -184,6 +193,12 @@ def save(self, file=None): except KeyError: cfg["CFGFILE"] = None cfg["_guiState"] = self._guiState.saveDict() + variables = self._propertyCollection.getVariables() + if any(not variables.isReadonly(k) for k in variables.keys()): + cfg["variables"] = { + k: variables.getraw(k) + for k in variables.keys() if not variables.isReadonly(k) + } cfg["composite_filters"] = [cf.save() for cf in self._compositeFilters] cfg["applications"] = [app.save() for app in self._applications] self.configNameChanged.emit(cfg["CFGFILE"]) diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index 91bfd59..2a9a487 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -98,14 +98,15 @@ def _createFilterAndUpdate(self): if self._pluginClass is cnexxT.QSharedPointer_nexxT_Filter: self._pluginClass = tempEnv.getPlugin().data().__class__ - def createFilter(self): + def createFilter(self, propColl=None): """ Creates the filter for real usage. State is CONSTRUCTED. This function is thread safe and can be called from multiple threads. :return: None """ # called from threads - res = FilterEnvironment(self._library, self._factoryFunction, self._propertyCollectionImpl) + res = FilterEnvironment(self._library, self._factoryFunction, + self._propertyCollectionImpl if propColl is None else propColl) with QMutexLocker(self._portMutex): for p in self._ports: if p.dynamic(): diff --git a/nexxT/core/Graph.py b/nexxT/core/Graph.py index 0a92c9a..14671f5 100644 --- a/nexxT/core/Graph.py +++ b/nexxT/core/Graph.py @@ -16,6 +16,7 @@ from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl from nexxT.core.Utils import assertMainThread, handleException from nexxT.core.Exceptions import NexTRuntimeError, PropertyCollectionChildNotFound, CompositeRecursion +from nexxT.core.Variables import Variables logger = logging.getLogger(__name__) @@ -96,7 +97,11 @@ def addNode(self, library, factoryFunction, suggestedName=None, propColl = self._properties.getChildCollection(name) except PropertyCollectionChildNotFound: propColl = PropertyCollectionImpl(name, self._properties) + if factoryFunction == "compositeNode" and hasattr(library, "checkRecursion"): + propColl = PropertyCollectionImpl(name, propColl) propColl.propertyChanged.connect(self.setDirty) + propColl.getVariables().variableAddedOrChanged.connect(self.setDirty) + propColl.getVariables().variableDeleted.connect(self.setDirty) filterMockup = FilterMockup(library, factoryFunction, propColl, self) self._filters[name] = filterMockup for din in dynamicInputPorts: diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index e1d7c9c..26b0b16 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -53,6 +53,7 @@ def __init__(self, name, parentPropColl, loadedFromConfig=None, variables=None): self._properties = {} if variables is None: self._vars = Variables(parent=parentPropColl._vars if parentPropColl is not None else None) + self._vars.setObjectName("propcoll:" + name) else: self._vars = variables assert parentPropColl is None # this should be the root property @@ -108,7 +109,7 @@ def getVariables(self): """ return self._vars - def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None, variables=None): """ Return the value of the given property, creating a new property if it doesn't exist. :param name: the name of the property @@ -158,7 +159,9 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle if not p.useEnvironment: p.value = p.handler.validate(p.handler.fromConfig(p.value)) else: - p.handler.validate(self._vars.subst(p.value)) + if variables is None: + variables = self._vars + p.handler.validate(variables.subst(p.value)) except Exception as e: raise PropertyParsingError( f"Error parsing property {name} from {repr(l)} (original exception: {str(e)})") from e @@ -179,7 +182,7 @@ def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandle return p.value @Slot(str) - def getProperty(self, name, subst=True): + def getProperty(self, name, subst=True, variables=None): self._accessed = True with QMutexLocker(self._propertyMutex): if name not in self._properties: @@ -187,7 +190,9 @@ def getProperty(self, name, subst=True): p = self._properties[name] p.used = True if p.useEnvironment and subst: - return p.handler.validate(self._vars.subst(p.value)) + if variables is None: + variables = self._vars + return p.handler.validate(variables.subst(p.value)) return p.value def getPropertyDetails(self, name): @@ -345,18 +350,119 @@ def deleteChild(self, name): if nexxT.shiboken.isValid(cc): # pylint: disable=no-member nexxT.shiboken.delete(cc) # pylint: disable=no-member - def evalpath(self, path): + def evalpath(self, path, variables=None): """ Evaluates the string path. If it is an absolute path it is unchanged, otherwise it is converted to an absolute path relative to the config file path. :param path: a string :return: absolute path as string """ - spath = self._vars.subst(path) + if variables is None: + variables = self._vars + spath = variables.subst(path) if spath != path or not Path(spath).is_absolute(): logger.warning("Deprecated: Implicit substitution or relative paths to the config file. Consider to use " "explicit variable substitution with ${CFG_DIR} to reference the directory of the config " "file instead. Found while evaluating %s.", path) if not Path(spath).is_absolute(): - spath = str((Path(self._vars.subst("$CFG_DIR")) / spath).absolute()) + spath = str((Path(variables.subst("$CFG_DIR")) / spath).absolute()) return spath + +class PropertyCollectionProxy(PropertyCollection): + """ + This class proxies to a PropertyCollection object but uses a different instance of variables + """ + propertyChanged = Signal(object, str) + + def __init__(self, proxiedPropColl, variables): + PropertyCollection.__init__(self) + self._proxiedPropColl = proxiedPropColl + self._vars = variables + assertMainThread() + self._propertyMutex = QRecursiveMutex() + proxiedPropColl.propertyChanged.connect(self._propertyChanged) + self.setObjectName(self._proxiedPropColl.objectName()) + + def _propertyChanged(self, propColl, name): + self.propertyChanged.emit(self, name) + + def getChildCollection(self, name): + """ + Return child property collection with given name + :param name: the name of the child + :return: PropertyCollection instance + """ + return self._proxiedPropColl.getChildCollection(name) + + def getVariables(self): + """ + Return the associated variables instance. + """ + return self._vars + + def defineProperty(self, name, defaultVal, helpstr, options=None, propertyHandler=None): + """ + Return the value of the given property, creating a new property if it doesn't exist. + :param name: the name of the property + :param defaultVal: the default value of the property. Note that this value will be used to determine the + property's type. Currently supported types are string, int and float + :param helpstr: a help string for the user + :param options: a dict mapping string to qvariant (common options: min, max, enum) + :param propertyHandler: a PropertyHandler instance, or None for automatic choice according to defaultVal + :return: the current value of this property + """ + return self._proxiedPropColl.defineProperty(name, defaultVal, helpstr, options, propertyHandler, + variables=self._vars) + + @Slot(str) + def getProperty(self, name, subst=True): + """ + See PropertyCollectionImpl.getProperty for details + """ + return self._proxiedPropColl.getProperty(name, subst, variables=self._vars) + + def getPropertyDetails(self, name): + """ + returns the property details of the property identified by name. + :param name: the property name + :return: a Property instance + """ + return self._proxiedPropColl.getPropertyDetails(self, name) + + def getAllPropertyNames(self): + """ + Query all property names handled in this collection + :return: list of strings + """ + return self._proxiedPropColl.getAllPropertyNames() + + @Slot(str, str) + def setProperty(self, name, value): + """ + Set the value of a named property. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._proxiedPropColl.setProperty(name, value) + + @Slot(str, str) + def setVarProperty(self, name, value): + """ + Set the value of a named property using an variable substitution. + + :param name: property name + :param value: the value to be set + :return: None + """ + self._proxiedPropColl.setVarProperty(name, value) + + def evalpath(self, path): + """ + Evaluates the string path. If it is an absolute path it is unchanged, otherwise it is converted + to an absolute path relative to the config file path. + :param path: a string + :return: absolute path as string + """ + return self._proxiedPropColl.evalpath(path, variables=self._vars) diff --git a/nexxT/core/SubConfiguration.py b/nexxT/core/SubConfiguration.py index 4ff65f2..3b1dfc0 100644 --- a/nexxT/core/SubConfiguration.py +++ b/nexxT/core/SubConfiguration.py @@ -160,6 +160,10 @@ def load(self, cfg, compositeLookup): raise NexTInternalError("addNode(...) has set unexpected name for node.") # make sure that the filter is instantiated and the port information is updated immediately self._graph.getMockup(n["name"]).createFilterAndUpdate() + if "variables" in n: + variables = self._graph.getMockup(n["name"]).getPropertyCollectionImpl().getVariables() + for k in n["variables"]: + variables[k] = n["variables"][k] for c in cfg["connections"]: contuple = self.connectionStringToTuple(c) self._graph.addConnection(*contuple[:-1]) @@ -223,6 +227,11 @@ def adaptLibAndFactory(lib, factory): pass except PropertyCollectionPropertyNotFound: pass + variables = p.getVariables() + if len(variables.keys()) > 0: + ncfg["variables"] = {} + for k in variables.keys(): + ncfg["variables"][k] = variables.getraw(k) ncfg["properties"] = p.saveDict() cfg["nodes"].append(ncfg) cfg["connections"] = [] diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index 3e196ce..a4ab6b6 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -119,7 +119,7 @@ def cleanup(self): self._mockups.clear() logger.internal("Thread cleanup done") - def addMockup(self, name, mockup): + def addMockup(self, name, mockup, propColl): """ Add a FilterMockup instance by name. :param name: name of the filter @@ -128,7 +128,7 @@ def addMockup(self, name, mockup): """ if name in self._mockups: raise NodeExistsError(name) - self._mockups[name] = mockup + self._mockups[name] = (mockup, propColl) def getFilter(self, name): """ @@ -179,10 +179,10 @@ def performOperation(self, operation, barrier): # wait for all threads barrier.wait() # perform operation for all filters - for name, mockup in self._mockups.items(): + for name, (mockup, propColl) in self._mockups.items(): try: if operation == "create": - res = mockup.createFilter() + res = mockup.createFilter(propColl) res.setParent(self) self._filters[name] = res self._filter2name[res] = name diff --git a/nexxT/core/Variables.py b/nexxT/core/Variables.py index 76f04df..6733fef 100644 --- a/nexxT/core/Variables.py +++ b/nexxT/core/Variables.py @@ -81,16 +81,28 @@ def __delitem__(self, key): super().__delitem__(key) self._variables.variableDeleted.emit(key) - variableAddedOrChanged = Signal(str, str) variableDeleted = Signal(str) def __init__(self, parent = None): - self._parent = parent + self._parent = None + self.setParent(parent) self._readonly = set() self._vars = Variables.VarDict(self) super().__init__() + def copyAndReparent(self, newParent): + """ + Create a copy and reparent to the given parent. + + :param newParent: a Variables instance or None + """ + res = Variables(newParent) + for k in self.keys(): + res[k] = self.getraw(k) + res._readonly = self._readonly.copy() + return res + def setParent(self, parent): """ reparent the variable class (for lookups of unknown variables) diff --git a/nexxT/examples/framework/example.json b/nexxT/examples/framework/example.json index 35bb3c4..341c42d 100644 --- a/nexxT/examples/framework/example.json +++ b/nexxT/examples/framework/example.json @@ -2,6 +2,9 @@ "_guiState": { "PlaybackControl_showAllFiles": 0 }, + "variables": { + "SRC": "fail" + }, "composite_filters": [ { "name": "visualization", @@ -44,7 +47,7 @@ "thread": "main", "properties": { "caption": { - "subst": false, + "subst": true, "value": "Processed" }, "scale": { @@ -66,8 +69,8 @@ "thread": "main", "properties": { "caption": { - "subst": false, - "value": "Original" + "subst": true, + "value": "Original - $SRC" }, "scale": { "subst": false, @@ -201,6 +204,9 @@ "dynamicOutputPorts": [], "staticOutputPorts": [], "thread": "main", + "variables": { + "SRC": "live" + }, "properties": {} } ], @@ -256,6 +262,9 @@ "dynamicOutputPorts": [], "staticOutputPorts": [], "thread": "main", + "variables": { + "SRC": "sim" + }, "properties": {} }, { @@ -299,4 +308,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/nexxT/services/SrvConfiguration.py b/nexxT/services/SrvConfiguration.py index 62ba408..cd08973 100644 --- a/nexxT/services/SrvConfiguration.py +++ b/nexxT/services/SrvConfiguration.py @@ -724,6 +724,8 @@ def setData(self, index, value, role):# pylint: disable=too-many-return-statemen return False elif index.column() == 2: p = item.property.getPropertyDetails(item.name) + if role == Qt.CheckStateRole: + value = False if Qt.CheckState(value) == Qt.Unchecked else True if value and not p.useEnvironment: item.property.setVarProperty(item.name, str(item.property.getProperty(item.name))) elif not value and p.useEnvironment: diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 11d5732..16bce43 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -440,6 +440,8 @@ def _substWindowTitle(title, theFilter): if ("${COMPOSITENAME}" in title or "${FILTERNAME}" in title or "${FULLQUALIFIEDFILTERNAME}" in title): + logger.warning("Deprecated: implicit variable substitution for window titles - " + "use explicit variable substitution instead.") if isinstance(theFilter, Filter): name = theFilter.environment().getFullQualifiedName() if "/" in name: diff --git a/nexxT/tests/core/test_ActiveApplication.py b/nexxT/tests/core/test_ActiveApplication.py index 340eef7..f425cf7 100644 --- a/nexxT/tests/core/test_ActiveApplication.py +++ b/nexxT/tests/core/test_ActiveApplication.py @@ -29,12 +29,26 @@ def simple_setup(multithread, sourceFreq, sinkTime, activeTime_s, dynamicFilter) try: class DummySubConfig(object): + class DummyConfig: + def __init__(self): + self.pc = PropertyCollectionImpl("root", None) + + def propertyCollection(self): + return self.pc + def __init__(self): + self.dummyConfig = DummySubConfig.DummyConfig() self.pc = PropertyCollectionImpl("root", None) + def getConfiguration(self): + return self.dummyConfig + def getPropertyCollection(self): return self.pc + def getName(self): + return "dummy_subconfig" + fg = FilterGraph(DummySubConfig()) n1 = fg.addNode("pyfile://" + os.path.dirname(__file__) + "/../interface/SimpleStaticFilter.py", "SimpleSource") p = fg.getMockup(n1).getPropertyCollectionImpl() diff --git a/nexxT/tests/integration/composite.json b/nexxT/tests/integration/composite.json new file mode 100644 index 0000000..b08743b --- /dev/null +++ b/nexxT/tests/integration/composite.json @@ -0,0 +1,367 @@ +{ + "_guiState": { + "MainWindow_framerate": { + "subst": false, + "value": 25 + }, + "PlaybackControl_showAllFiles": { + "subst": false, + "value": 0 + } + }, + "variables": { + "ROOTVAR": "root" + }, + "composite_filters": [ + { + "name": "comp1", + "nodes": [ + { + "name": "CompositeInput", + "library": "composite://port", + "factoryFunction": "CompositeInput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "CompositeOutput", + "library": "composite://port", + "factoryFunction": "CompositeOutput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "comp2", + "library": "composite://ref", + "factoryFunction": "comp2", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP2VAR": "c" + }, + "properties": {} + }, + { + "name": "comp3", + "library": "composite://ref", + "factoryFunction": "comp3", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP3VAR": "d" + }, + "properties": {} + }, + { + "name": "RootRef", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $ROOTVAR" + } + } + }, + { + "name": "Comp1Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP1VAR" + } + } + }, + { + "name": "Comp2Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP2VAR" + } + } + }, + { + "name": "Comp3Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP3VAR" + } + } + } + ], + "connections": [], + "_guiState": {} + }, + { + "name": "comp2", + "nodes": [ + { + "name": "CompositeInput", + "library": "composite://port", + "factoryFunction": "CompositeInput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "CompositeOutput", + "library": "composite://port", + "factoryFunction": "CompositeOutput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "RootRef", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $ROOTVAR" + } + } + }, + { + "name": "Comp1Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP1VAR" + } + } + }, + { + "name": "Comp2Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP2VAR" + } + } + }, + { + "name": "Comp3Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP3VAR" + } + } + } + ], + "connections": [], + "_guiState": {} + }, + { + "name": "comp3", + "nodes": [ + { + "name": "CompositeInput", + "library": "composite://port", + "factoryFunction": "CompositeInput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "CompositeOutput", + "library": "composite://port", + "factoryFunction": "CompositeOutput", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": {} + }, + { + "name": "RootRef", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $ROOTVAR" + } + } + }, + { + "name": "Comp1Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP1VAR" + } + } + }, + { + "name": "Comp2Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP2VAR" + } + } + }, + { + "name": "Comp3Ref", + "library": "pyfile:///tmp/thefilter.py", + "factoryFunction": "TheFilter", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "properties": { + "string": { + "subst": true, + "value": "$FULLQUALIFIEDFILTERNAME : $COMP3VAR" + } + } + } + ], + "connections": [], + "_guiState": {} + } + ], + "applications": [ + { + "name": "application", + "_guiState": {}, + "nodes": [ + { + "name": "comp1_2", + "library": "composite://ref", + "factoryFunction": "comp1", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP1VAR": "b" + }, + "properties": {} + }, + { + "name": "comp1_1", + "library": "composite://ref", + "factoryFunction": "comp1", + "dynamicInputPorts": [], + "staticInputPorts": [], + "dynamicOutputPorts": [], + "staticOutputPorts": [], + "thread": "main", + "variables": { + "COMP1VAR": "a" + }, + "properties": {} + } + ], + "connections": [] + }, + { + "name": "application_2", + "_guiState": {}, + "nodes": [], + "connections": [] + } + ] +} \ No newline at end of file diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index bb831d5..aeb0908 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -62,6 +62,7 @@ class ContextMenuEntry(str): CM_SETTHREAD = ContextMenuEntry("Set thread ...") CM_RENAMEDYNPORT = ContextMenuEntry("Rename dynamic port ...") CM_REMOVEDYNPORT = ContextMenuEntry("Remove dynamic port ...") +CM_ADDVARIABLE = ContextMenuEntry("Add variable ...") CONFIG_MENU_DEINITIALIZE = ContextMenuEntry("Deinitialize") CONFIG_MENU_INITIALIZE = ContextMenuEntry("Initialize") LM_WARNING = ContextMenuEntry("Warning") @@ -280,7 +281,7 @@ def addConnectionToGraphEditor(self, graphEditView, p1, p2): self.qtbot.mouseMove(graphEditView.viewport(), pos2, delay=self.delay) self.qtbot.mouseRelease(graphEditView.viewport(), Qt.LeftButton, pos=pos2, delay=self.delay) - def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expectedVal=None): + def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expectedVal=None, indirect=False): """ Sets a filter property in the configuration gui service. :param conf: the configuration gui service @@ -318,6 +319,14 @@ def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expe assert row is not None # start the editor by pressing F2 on the property value idxPropVal = conf.model.index(row, 1, idxFilter) + idxPropIndirect = conf.model.index(row, 2, idxFilter) + cindirect = conf.model.data(idxPropIndirect, Qt.CheckStateRole) != Qt.Unchecked + logger.info("cindirect=%s ==? indirect=%s", repr(cindirect), repr(indirect)) + if cindirect != indirect: + conf.model.setData(idxPropIndirect, True, Qt.EditRole) + self.qtbot.wait(self.delay) + cindirect = conf.model.data(idxPropIndirect, Qt.CheckStateRole) != Qt.Unchecked + assert cindirect == indirect conf.treeView.scrollTo(idxPropVal) region = conf.treeView.visualRegionForSelection(QItemSelection(idxPropVal, idxPropVal)) self.qtbot.mouseMove(conf.treeView.viewport(), pos=region.boundingRect().center(), delay=self.delay) @@ -408,6 +417,10 @@ def assertLogItem(log, expectedLevel, expectedMsg): if not found: raise RuntimeError("expected message %s:%s not found in log", expectedLevel, expectedMsg) + @staticmethod + def clearLog(log): + log.logWidget.clear() + @staticmethod def noWarningsInLog(log, ignore=[]): """ @@ -1679,3 +1692,252 @@ def test(self): def test_reload(qtbot, xvfb, keep_open, delay, tmpdir): test = ReloadTest(qtbot, xvfb, keep_open, delay, tmpdir) test.test() + + +class VariablesTest(GuiTestBase): + """ + Concrete test class for the test_variables test case + """ + + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _variables(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thefilter.py") + thefilter_py.write_text("""\ +import logging +from nexxT.interface import Filter + +logger = logging.getLogger(__name__) + +class TheFilter(Filter): + def __init__(self, env): + super().__init__(False, False, env) + pc = self.propertyCollection() + pc.defineProperty("bool_prop", False, "a boolean") + pc.defineProperty("unbound_float", 7., "an unbound float") + pc.defineProperty("low_bound_float", 7., "a low bound float", dict(min=-3)) + pc.defineProperty("high_bound_float", 7., "a high bound float", dict(max=123)) + pc.defineProperty("bound_float", 7., "a bound float", dict(min=6, max=1203)) + pc.defineProperty("unbound_int", 7, "an unbound integer") + pc.defineProperty("low_bound_int", 7, "a low bound integer", dict(min=-3)) + pc.defineProperty("high_bound_int", 7, "a high bound integer", dict(max=123)) + pc.defineProperty("bound_int", 7, "a bound integer", dict(min=6, max=1203)) + pc.defineProperty("string", "str", "an arbitrary string") + pc.defineProperty("enum", "v1", "an enum", dict(enum=["v1", "v2", "v3"])) + + def onOpen(self): + pc = self.propertyCollection() + for n in ["bool_prop", "unbound_float", "low_bound_float", "high_bound_float", "bound_float", + "unbound_int", "low_bound_int", "high_bound_int", "bound_int", "string", "enum"]: + logger.info("getProperty(%s) = %s", n, repr(pc.getProperty(n))) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + idxVariables = conf.model.index(2, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + # qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + appidx = conf.model.indexOfSubConfig(app) + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + logger.info("Filter: %s", repr(the_filter)) + # add variables + self.aw() + for name, value in [("BFALSE", "False"), ("BTRUE", "True"), + ("FHIGH", "3.4028235e+38"), ("FLOW", "-3.4028235e+38"), + ("IHIGH", "2147483647"), ("ILOW", "-2147483648"), + ("IM4", "-4"), ("I1", "1"), + ("EV1", "v1"), ("EV2", "v2"), ("EV3", "v3")]: + conf.treeView.scrollTo(idxVariables) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxVariables, idxVariables)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDVARIABLE)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText(name)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + def find_var_index(parentIndex, varname): + m = conf.model + for r in range(m.rowCount(parentIndex)): + idx = m.index(r, 0, parentIndex) + t = m.data(idx, Qt.DisplayRole) + if t == varname: + return m.index(r, 1, parentIndex) + idx = find_var_index(idxVariables, name) + assert idx is not None + conf.treeView.scrollTo(idx) + region = conf.treeView.visualRegionForSelection(QItemSelection(idx, idx)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + self.qtbot.mouseClick(conf.treeView.viewport(), Qt.LeftButton, pos=region.boundingRect().center(), + delay=self.delay) + self.qtbot.keyClick(conf.treeView.viewport(), Qt.Key_F2, delay=self.delay) + self.aw() + mw = Services.getService("MainWindow") + QTimer.singleShot(self.delay*2, lambda: self.enterText(value)) + self.qtbot.wait(self.delay*3) + assert conf.model.data(idx, Qt.DisplayRole) == value + logger.info("Added variable %s=%s", name, value) + tests = dict( + bool_prop=[("$BFALSE", repr(False)), ("$BTRUE", repr(True)), ("$NONEXIST", repr(False))], + unbound_float=[("$FHIGH", repr(3.4028235e+38)), ("$FLOW", repr(-3.4028235e+38)), + ("$NONEXIST", repr(0.0))], + low_bound_float=[("$FHIGH", repr(3.4028235e+38)), ("$FLOW", repr(-3.0)), ("$IM4", repr(-3.0)), + ("$I1", repr(1.0)), ("$NONEXIST", repr(0.0))], + high_bound_float=[("$FHIGH", repr(123.0)), ("$FLOW", repr(-3.4028235e+38)), ("$IM4", repr(-4.0)), + ("$I1", repr(1.0)), ("$NONEXIST", repr(0.0))], + bound_float=[("$FHIGH", repr(1203.0)), ("$FLOW", repr(6.0)), + ("$NONEXIST", repr(6.0))], + unbound_int=[("$IHIGH", repr(2147483647)), ("$ILOW", repr(-2147483648)), ("$IM4", repr(-4)), + ("$I1", repr(1)), ("$NONEXIST", repr(0))], + low_bound_int=[("$IHIGH", repr(2147483647)), ("$ILOW", repr(-3)), ("$IM4", repr(-3)), + ("$I1", repr(1)), ("$NONEXIST", repr(0))], + high_bound_int=[("$IHIGH", repr(123)), ("$ILOW", repr(-2147483648)), ("$IM4", repr(-4)), + ("$I1", repr(1)), ("$NONEXIST", repr(0))], + bound_int=[("$IHIGH", repr(1203)), ("$ILOW", repr(6)), ("$IM4", repr(6)), + ("$I1", repr(6)), ("$NONEXIST", repr(6))], + string=[("$IHIGH", repr("2147483647")), ("$FILTERNAME", repr("TheFilter")), + ("$FULLQUALIFIEDFILTERNAME", repr("/TheFilter")), ("$COMPOSITENAME", repr("")), + ("$APPNAME", repr("application"))], + enum=[("$EV1", repr("v1")), ("$EV2", repr("v2")), ("$EV3", repr("v3")), ("$NONEXIST", repr("v1"))] + ) + for tidx in range(max(len(tests[k]) for k in tests)): + expectedLogs = [] + logger.info("test_gui:variables:start test cycle") + for k in tests: + if tidx < len(tests[k]): + t = tests[k][tidx] + logger.info("test_gui:variables:set property %s->%s", k, t[0]) + self.setFilterProperty(conf, app, "TheFilter", k, t[0], t[0], indirect=True) + expectedLogs.append("getProperty(%s) = %s" % (k, t[1])) + logger.info("test_gui:variables:Initializing app") + self.cmContextMenu(conf, appidx, CM_INIT_APP) + logger.info("test_gui:variables:wait") + self.qtbot.wait(1000) + for logMsg in expectedLogs: + logger.info("test_gui:variables:checklog") + self.assertLogItem(log, "INFO", logMsg) + logger.info("test_gui:variables:clearlog") + self.clearLog(log) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + self.aw() + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _composite(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thefilter.py") + thefilter_py.write_text("""\ +import logging +from nexxT.interface import Filter + +logger = logging.getLogger(__name__) + +class TheFilter(Filter): + def __init__(self, env): + super().__init__(False, False, env) + pc = self.propertyCollection() + pc.defineProperty("string", "str", "an arbitrary string") + + def onOpen(self): + pc = self.propertyCollection() + logger.info("getProperty(string) = %s", repr(pc.getProperty("string"))) +""") + config_file = Path(self.tmpdir) / "composite.json" + config_file.write_text((Path(__file__).parent / "composite.json").read_text()) + try: + # load config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + + conf.loadConfig(str(config_file)) + logger.info("test_gui:variables:Initializing app") + app = conf.configuration().applicationByName("application") + appidx = conf.model.indexOfSubConfig(app) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + logger.info("test_gui:variables:wait") + self.qtbot.wait(1000) + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/Comp1Ref : b'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/Comp2Ref : c'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp2/Comp3Ref : $COMP3VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/Comp1Ref : b'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/comp3/Comp3Ref : d'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/Comp1Ref : b'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_2/Comp3Ref : $COMP3VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/Comp1Ref : a'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/Comp2Ref : c'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp2/Comp3Ref : $COMP3VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/Comp1Ref : a'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/comp3/Comp3Ref : d'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/RootRef : root'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/Comp1Ref : a'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/Comp2Ref : $COMP2VAR'") + self.assertLogItem(log, "INFO", "getProperty(string) = '/comp1_1/Comp3Ref : $COMP3VAR'") + finally: + if not self.keep_open: + if conf.configuration().dirty(): + self.aw() + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._variables) + startNexT(None, None, [], [], True) + + + def test_composite(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._composite) + startNexT(None, None, [], [], True) + + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_variables(qtbot, xvfb, keep_open, delay, tmpdir): + test = VariablesTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_variablesComposite(qtbot, xvfb, keep_open, delay, tmpdir): + test = VariablesTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_composite() + From 23ae2081fd4fc4a8d9fa2766dbf92dbd0f541986 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 3 Dec 2023 11:07:02 +0100 Subject: [PATCH 06/17] change C++ API of propertyChanged signal and test that this really works now --- .gitignore | 3 - nexxT/core/BaseGraph.py | 2 +- nexxT/core/PropertyCollectionImpl.py | 1 - nexxT/core/Thread.py | 1 - nexxT/core/Utils.py | 2 +- nexxT/include/nexxT/PropertyCollection.hpp | 2 +- nexxT/services/SrvPlaybackControl.py | 3 +- nexxT/services/SrvProfiling.py | 3 +- nexxT/services/gui/Configuration.py | 4 +- nexxT/services/gui/GraphEditor.py | 15 +++ nexxT/src/cnexxT.xml | 2 +- nexxT/tests/__init__.py | 6 + nexxT/tests/integration/test_gui.py | 139 +++++++++++++++++++++ nexxT/tests/src/Plugins.cpp | 21 +--- nexxT/tests/src/Properties.cpp | 60 +++++++++ nexxT/tests/src/Properties.hpp | 27 ++++ nexxT/tests/src/SConscript.py | 53 +++----- setup.py | 1 + workspace/sconstools3/qt6/__init__.py | 14 ++- 19 files changed, 285 insertions(+), 74 deletions(-) create mode 100644 nexxT/tests/src/Properties.cpp create mode 100644 nexxT/tests/src/Properties.hpp diff --git a/.gitignore b/.gitignore index 8d5c70a..ccfd8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ __pycache__/ *.dll binary/ -# installed include files -include/ - # scons .sconsign.dblite workspace/build diff --git a/nexxT/core/BaseGraph.py b/nexxT/core/BaseGraph.py index 85317c8..9a64bd2 100644 --- a/nexxT/core/BaseGraph.py +++ b/nexxT/core/BaseGraph.py @@ -410,4 +410,4 @@ def allOutputPorts(self, node): """ if not node in self._nodes: raise NodeNotFoundError(node) - return self._nodes[node]["outports"] + return self._nodes[node]["outports"] \ No newline at end of file diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index a54fcf5..26c99e7 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -41,7 +41,6 @@ class PropertyCollectionImpl(PropertyCollection): This class represents a collection of properties. These collections are organized in a tree, such that there are parent/child relations. """ - propertyChanged = Signal(object, str) propertyAdded = Signal(object, str) propertyRemoved = Signal(object, str) childAdded = Signal(object, str) diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index 55ecbf8..1a44339 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -172,7 +172,6 @@ def performOperation(self, operation, barrier): "operation %s happening during receiveAsync's processEvents. This shouldn't be happening.", operation) MethodInvoker(dict(object=self, method="performOperation", thread=self.thread()), Qt.QueuedConnection, operation, barrier) - return barrier.wait() if operation in self._operations: diff --git a/nexxT/core/Utils.py b/nexxT/core/Utils.py index f91c49f..ef10d8f 100644 --- a/nexxT/core/Utils.py +++ b/nexxT/core/Utils.py @@ -16,7 +16,7 @@ import os.path import sqlite3 import time -from nexxT.Qt.QtCore import (QObject, Signal, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, +from nexxT.Qt.QtCore import (QObject, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, QMutexLocker, QRecursiveMutex, QTimer, Qt, QPoint, QMetaObject) from nexxT.Qt.QtGui import QColor, QPainter, QTextLayout, QTextOption from nexxT.Qt.QtWidgets import QFrame, QSizePolicy diff --git a/nexxT/include/nexxT/PropertyCollection.hpp b/nexxT/include/nexxT/PropertyCollection.hpp index 0c59c2e..810cc52 100644 --- a/nexxT/include/nexxT/PropertyCollection.hpp +++ b/nexxT/include/nexxT/PropertyCollection.hpp @@ -143,7 +143,7 @@ namespace nexxT See \verbatim embed:rst:inline :py:attr:`nexxT.interface.PropertyCollections.PropertyCollection.propertyChanged` \endverbatim */ - void propertyChanged(const PropertyCollection &sender, const QString &name); + void propertyChanged(nexxT::PropertyCollection *sender, const QString &name); }; }; diff --git a/nexxT/services/SrvPlaybackControl.py b/nexxT/services/SrvPlaybackControl.py index 773f27d..d941887 100644 --- a/nexxT/services/SrvPlaybackControl.py +++ b/nexxT/services/SrvPlaybackControl.py @@ -279,8 +279,7 @@ def removeConnections(self, playbackDevice): if len(found) > 0: for devid, _ in found: del self._registeredDevices[devid] - for devid, dev in found: - del dev + del found logger.debug("disconnected connections of playback device. number of devices left: %d", len(self._registeredDevices)) MethodInvoker(dict(object=self, method="_updateFeatureSet", thread=mainThread()), Qt.QueuedConnection) diff --git a/nexxT/services/SrvProfiling.py b/nexxT/services/SrvProfiling.py index 6813d24..6783964 100644 --- a/nexxT/services/SrvProfiling.py +++ b/nexxT/services/SrvProfiling.py @@ -258,8 +258,7 @@ def deregisterThread(self): self._threadSpecificProfiling[t].timer.stop() todel.append(self._threadSpecificProfiling[t]) del self._threadSpecificProfiling[t] - for tsp in todel: - del tsp + del todel self.threadDeregistered.emit(t.objectName()) @Slot() diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index d4c91fa..b70a27a 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -233,10 +233,10 @@ def _addGraphView(self, subConfig): graphDw.visibleChanged.connect(self._removeGraphViewFromList) def _subConfigRemoved(self, subConfigName, configType): - self.__subConfigRemoved(subConfigName, configType) + self._subConfigRemovedImpl(subConfigName, configType) @handleException - def __subConfigRemoved(self, subConfigName, configType): + def _subConfigRemovedImpl(self, subConfigName, configType): g = self._configuration.subConfigByNameAndTye(subConfigName, configType).getGraph() if nexxT.shiboken.isValid(g): for gv in self._graphViews: diff --git a/nexxT/services/gui/GraphEditor.py b/nexxT/services/gui/GraphEditor.py index 6bf4c59..dafbdd0 100644 --- a/nexxT/services/gui/GraphEditor.py +++ b/nexxT/services/gui/GraphEditor.py @@ -1255,18 +1255,33 @@ def onConnectionRemove(self): item.portTo.nodeItem.name, item.portTo.name) def onConnSetNonBlocking(self): + """ + Sets the conmnection to non blocking mode. + + :return: + """ item = self.itemOfContextMenu self.graph.setConnectionProperties(item.portFrom.nodeItem.name, item.portFrom.name, item.portTo.nodeItem.name, item.portTo.name, dict(width=0)) item.sync() def onConnSetBlocking(self): + """ + Sets the conmnection to blocking mode. + + :return: + """ item = self.itemOfContextMenu self.graph.setConnectionProperties(item.portFrom.nodeItem.name, item.portFrom.name, item.portTo.nodeItem.name, item.portTo.name, dict(width=1)) item.sync() def onConnSetCustom(self): + """ + Sets the connection to a custom width. + + :return: + """ item = self.itemOfContextMenu c = item.portFrom.nodeItem.name, item.portFrom.name, item.portTo.nodeItem.name, item.portTo.name width = self.graph.getConnectionProperties(*c)["width"] diff --git a/nexxT/src/cnexxT.xml b/nexxT/src/cnexxT.xml index a861d1c..12a56d7 100644 --- a/nexxT/src/cnexxT.xml +++ b/nexxT/src/cnexxT.xml @@ -98,7 +98,7 @@ - + diff --git a/nexxT/tests/__init__.py b/nexxT/tests/__init__.py index 147a6b5..c43febd 100644 --- a/nexxT/tests/__init__.py +++ b/nexxT/tests/__init__.py @@ -19,3 +19,9 @@ "binary" / "${NEXXT_PLATFORM}" / "${NEXXT_VARIANT}" / "test_plugins").absolute()), "TestExceptionFilter" ) + +PropertyReceiver = FilterSurrogate( + "binary://" + str((Path(__file__).parent / + "binary" / "${NEXXT_PLATFORM}" / "${NEXXT_VARIANT}" / "test_plugins").absolute()), + "PropertyReceiver" +) \ No newline at end of file diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index 79f13ba..967eb35 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -51,6 +51,7 @@ class ContextMenuEntry(str): CM_FILTER_LIBRARY_TESTS_NEXXT = ContextMenuEntry("nexxT") CM_FILTER_LIBRARY_CSIMPLESOURCE = ContextMenuEntry("CSimpleSource") CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER = ContextMenuEntry("PySimpleStaticFilter") +CM_FILTER_LIBRARY_CPROPERTY_RECEIVER = ContextMenuEntry("CPropertyReceiver") CM_FILTER_LIBRARY_PYSIMPLEVIEW = ContextMenuEntry("PySimpleView") CM_FILTER_LIBRARY_HDF5WRITER = ContextMenuEntry("HDF5Writer") CM_FILTER_LIBRARY_HDF5READER = ContextMenuEntry("HDF5Reader") @@ -261,6 +262,12 @@ def removeNodeFromGraph(self, graphEditView, node): QTimer.singleShot(2*self.delay, lambda: self.enterText("")) self.gsContextMenu(graphEditView, pos) + def setThreadOfNode(self, graphEditView, node, thread): + pos = node.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(2*self.delay, lambda: self.enterText(thread)) + self.gsContextMenu(graphEditView, pos) + def addConnectionToGraphEditor(self, graphEditView, p1, p2): """ Adds a connection in the nexxT graph editor @@ -1031,6 +1038,112 @@ def onInit(self): QTimer.singleShot(self.delay, self.clickDiscardChanges) mw.close() + def _prop_changed(self, variant): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + if variant == "c": + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_CPROPERTY_RECEIVER) + self.setThreadOfNode(gev, the_filter, "non_main") + self.qtbot.wait(self.delay) + logger.info("Filter: %s", repr(the_filter)) + elif variant == "py": + thefilter_py = (Path(self.tmpdir) / "thepropfilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter +import logging + +class CPropertyReceiver(Filter): + def __init__(self, env): + super().__init__(False, False, env) + + def onInit(self): + pc = self.propertyCollection() + pc.defineProperty("int", 1, "an integer property") + pc.defineProperty("float", 10.0, "a float property") + pc.defineProperty("str", "Hello World", "a string property") + pc.defineProperty("bool", False, "a bool property") + pc.defineProperty("enum", "v1", "an enum property", options=dict(enum=["v1", "v2", "v3"])) + pc.propertyChanged.connect(self.propertyChanged) + + def onDeinit(self): + pc = self.propertyCollection() + pc.propertyChanged.disconnect(self.propertyChanged) + + def propertyChanged(self, pc, name): + v = pc.getProperty(name) + logging.getLogger(__name__).info("propertyChanged %s is %s", name, v) +""" + ) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "CPropertyReceiver") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + else: + assert False + # init application + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + + self.setFilterProperty(conf, app, "CPropertyReceiver", "int", + ["7", Qt.Key_Return], "7") + self.setFilterProperty(conf, app, "CPropertyReceiver", "float", + "1.0", "1.0") + self.setFilterProperty(conf, app, "CPropertyReceiver", "str", + "changed", "changed") + self.setFilterProperty(conf, app, "CPropertyReceiver", "bool", + [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "CPropertyReceiver", "enum", + [Qt.Key_Down, Qt.Key_Return], "v2") + + expectedLogItems = [ + "propertyChanged int is 7", + "propertyChanged float is 1" if variant == "c" else "propertyChanged float is 1.0", + "propertyChanged str is changed", + "propertyChanged bool is true" if variant == "c" else "propertyChanged bool is True", + "propertyChanged enum is v2", + ] + + self.qtbot.wait(1000) + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + for row in range(numRows): + level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + if len(expectedLogItems) > 0 and level == "INFO" and msg in expectedLogItems[0]: + expectedLogItems = expectedLogItems[1:] + assert level not in ["WARN", "WARNING", "ERROR"] + assert len(expectedLogItems) == 0 + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + def test(self): """ test property editing in config editor @@ -1047,6 +1160,20 @@ def test_dyn(self): QTimer.singleShot(self.delay, self._dynamic_properties) startNexT(None, None, [], [], True) + def test_propChangedC(self): + """ + test connections to propertyChanged signals + """ + QTimer.singleShot(self.delay, lambda: self._prop_changed("c")) + startNexT(None, None, [], [], True) + + def test_propChangedPy(self): + """ + test connections to propertyChanged signals + """ + QTimer.singleShot(self.delay, lambda: self._prop_changed("py")) + startNexT(None, None, [], [], True) + @pytest.mark.gui @pytest.mark.parametrize("delay", [300]) def test_properties(qtbot, xvfb, keep_open, delay, tmpdir): @@ -1059,6 +1186,18 @@ def test_dyn_properties(qtbot, xvfb, keep_open, delay, tmpdir): test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) test.test_dyn() +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_prop_changed_c(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_propChangedC() + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_prop_changed_py(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_propChangedPy() + class GuiStateTest(GuiTestBase): """ Concrete test class for the guistate test case diff --git a/nexxT/tests/src/Plugins.cpp b/nexxT/tests/src/Plugins.cpp index 9008503..1468f7a 100644 --- a/nexxT/tests/src/Plugins.cpp +++ b/nexxT/tests/src/Plugins.cpp @@ -5,33 +5,16 @@ * THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. */ #include - -#if QT_VERSION < 0x060000 #include "AviFilePlayback.hpp" #include "CameraGrabber.hpp" #include "SimpleSource.hpp" #include "TestExceptionFilter.hpp" +#include "Properties.hpp" NEXXT_PLUGIN_DEFINE_START() NEXXT_PLUGIN_ADD_FILTER(VideoPlaybackDevice) NEXXT_PLUGIN_ADD_FILTER(CameraGrabber) NEXXT_PLUGIN_ADD_FILTER(SimpleSource) NEXXT_PLUGIN_ADD_FILTER(TestExceptionFilter) +NEXXT_PLUGIN_ADD_FILTER(PropertyReceiver) NEXXT_PLUGIN_DEFINE_FINISH() - -#else -/* QT 6.1.x does not support multimedia */ - -#include "AviFilePlayback.hpp" -#include "CameraGrabber.hpp" -#include "SimpleSource.hpp" -#include "TestExceptionFilter.hpp" - -NEXXT_PLUGIN_DEFINE_START() -NEXXT_PLUGIN_ADD_FILTER(VideoPlaybackDevice) -NEXXT_PLUGIN_ADD_FILTER(CameraGrabber) -NEXXT_PLUGIN_ADD_FILTER(SimpleSource) -NEXXT_PLUGIN_ADD_FILTER(TestExceptionFilter) -NEXXT_PLUGIN_DEFINE_FINISH() - -#endif \ No newline at end of file diff --git a/nexxT/tests/src/Properties.cpp b/nexxT/tests/src/Properties.cpp new file mode 100644 index 0000000..6c2b745 --- /dev/null +++ b/nexxT/tests/src/Properties.cpp @@ -0,0 +1,60 @@ +#include "Properties.hpp" +#include "nexxT/Logger.hpp" +#include "nexxT/PropertyCollection.hpp" + +using namespace nexxT; + +PropertyReceiver::PropertyReceiver(BaseFilterEnvironment *env) : + Filter(false, false, env) + {} + +PropertyReceiver::~PropertyReceiver() +{} + +void PropertyReceiver::onInit() +{ + propertyCollection()->defineProperty("int", 1, "an integer property", {{"min", 0}, {"max", 10}}); + propertyCollection()->defineProperty("float", 10.0, "a float property", {{"min", -1.0}, {"max", 100.0}}); + propertyCollection()->defineProperty("str", "Hello", "a string property"); + propertyCollection()->defineProperty("bool", false, "a bool property"); + propertyCollection()->defineProperty("enum", "v1", "an enum property", {{"enum", QStringList{"v1", "v2", "v3"}}}); + + if(!(bool) + connect(propertyCollection(), SIGNAL(propertyChanged(nexxT::PropertyCollection *, const QString &)), + this, SLOT(propertyChanged(nexxT::PropertyCollection *, const QString &))) + ) { + NEXXT_LOG_ERROR("connect failed!"); + } else { + } +} + +void PropertyReceiver::propertyChanged(nexxT::PropertyCollection *propcoll, const QString &name) +{ + QVariant v = propcoll->getProperty(name); + NEXXT_LOG_INFO(QString("propertyChanged %1 is %2").arg(name, v.toString())); +} + +void PropertyReceiver::onOpen() +{ +} + +void PropertyReceiver::onStart() +{ +} + +void PropertyReceiver::onStop() +{ +} + +void PropertyReceiver::onClose() +{ +} + +void PropertyReceiver::onDeinit() +{ + if( !disconnect(propertyCollection(), SIGNAL(propertyChanged(nexxT::PropertyCollection *, const QString &)), + this, SLOT(propertyChanged(nexxT::PropertyCollection *, const QString &))) ) + { + NEXXT_LOG_ERROR("disconnect failed!"); + } +} diff --git a/nexxT/tests/src/Properties.hpp b/nexxT/tests/src/Properties.hpp new file mode 100644 index 0000000..019a64b --- /dev/null +++ b/nexxT/tests/src/Properties.hpp @@ -0,0 +1,27 @@ +#ifndef PROPERTY_RECEIVER_HPP +#define PROPERTY_RECEIVER_HPP + +#include "nexxT/Filters.hpp" +#include "nexxT/NexxTPlugins.hpp" + +class PropertyReceiver : public nexxT::Filter +{ + Q_OBJECT +public: + NEXXT_PLUGIN_DECLARE_FILTER(PropertyReceiver) + + PropertyReceiver(nexxT::BaseFilterEnvironment *env); + virtual ~PropertyReceiver(); + + virtual void onInit(); + virtual void onOpen(); + virtual void onStart(); + virtual void onStop(); + virtual void onClose(); + virtual void onDeinit(); + +public slots: + void propertyChanged(nexxT::PropertyCollection *sender, const QString &name); +}; + +#endif diff --git a/nexxT/tests/src/SConscript.py b/nexxT/tests/src/SConscript.py index 153175e..aee8fee 100644 --- a/nexxT/tests/src/SConscript.py +++ b/nexxT/tests/src/SConscript.py @@ -9,42 +9,23 @@ Import("env") env = env.Clone() -if os.environ.get("PYSIDEVERSION", "6") in "52": - env.EnableQt5Modules(['QtCore', "QtMultimedia", "QtGui"]) - srcDir = Dir(".").srcnode() - - env.Append(CPPPATH=["../../src", "."], - LIBPATH=["../../src"], - LIBS=["nexxT"]) - - plugin = env.SharedLibrary("test_plugins", env.RegisterSources(Split(""" - SimpleSource.cpp - AviFilePlayback.cpp - TestExceptionFilter.cpp - Plugins.cpp - CameraGrabber.cpp - VideoGrabber.cpp - """))) - env.RegisterTargets(plugin) -elif os.environ.get("PYSIDEVERSION", "6") == "6": - env.EnableQt6Modules(['QtCore', "QtGui", "QtMultimedia"]) - srcDir = Dir(".").srcnode() - - env.Append(CPPPATH=[srcDir.Dir("../../include"), "."], - LIBPATH=["../../src"], - LIBS=["nexxT"]) - - plugin = env.SharedLibrary("test_plugins", env.RegisterSources(Split(""" - SimpleSource.cpp - AviFilePlayback.cpp - TestExceptionFilter.cpp - Plugins.cpp - VideoGrabber.cpp - CameraGrabber.cpp - """))) - env.RegisterTargets(plugin) -else: - raise RuntimeError("invalid env variable PYSIDEVERSION=%s" % os.environ["PYSIDEVERSION"]) +env.EnableQt6Modules(['QtCore', "QtGui", "QtMultimedia"]) +srcDir = Dir(".").srcnode() + +env.Append(CPPPATH=[srcDir.Dir("../../include"), "."], + LIBPATH=["../../src"], + LIBS=["nexxT"]) + +plugin = env.SharedLibrary("test_plugins", env.RegisterSources(Split(""" + SimpleSource.cpp + AviFilePlayback.cpp + TestExceptionFilter.cpp + Plugins.cpp + VideoGrabber.cpp + CameraGrabber.cpp + Properties.cpp +"""))) +env.RegisterTargets(plugin) installed = env.Install(srcDir.Dir("..").Dir("binary").Dir(env.subst("$deploy_platform")).Dir(env.subst("$variant")).abspath, plugin) env.RegisterTargets(installed) diff --git a/setup.py b/setup.py index 2469b8e..63dfa90 100644 --- a/setup.py +++ b/setup.py @@ -187,6 +187,7 @@ def is_pure(*args): 'tests.nexxT.PySimpleDynInFilter = nexxT.tests.interface.SimpleDynamicFilter:SimpleDynInFilter', 'tests.nexxT.PySimpleDynOutFilter = nexxT.tests.interface.SimpleDynamicFilter:SimpleDynOutFilter', 'tests.nexxT.CTestExceptionFilter = nexxT.tests:CTestExceptionFilter', + 'tests.nexxT.CPropertyReceiver = nexxT.tests:PropertyReceiver', 'tests.nexxT.PyTestExceptionFilter = nexxT.tests.interface.TestExceptionFilter:TestExceptionFilter', ], }, diff --git a/workspace/sconstools3/qt6/__init__.py b/workspace/sconstools3/qt6/__init__.py index 12a1b96..a7d084a 100644 --- a/workspace/sconstools3/qt6/__init__.py +++ b/workspace/sconstools3/qt6/__init__.py @@ -239,10 +239,16 @@ def __automoc_strategy_simple(self, env, moc_options, if cpp and self.qo_search.search(cpp_contents): # cpp file with Q_OBJECT macro found -> add moc # (to be included in cpp) - moc = env.Moc6(cpp) - env.Ignore(moc, moc) - if moc_options['debug']: - print("scons: qt6: found Q_OBJECT macro in '%s', moc'ing to '%s'" % (str(cpp), str(moc))) + # check if this has been built by a MOC builder + if (cpp.get_builder() == env['BUILDERS']['Moc6'].builder or + cpp.get_builder() == env['BUILDERS']['XMoc6'].builder): + if moc_options["debug"]: + print("scons: qt6: Ignoring source file %s since this has been built by moc" % str(cpp)) + else: + moc = env.Moc6(cpp) + env.Ignore(moc, moc) + if moc_options['debug']: + print("scons: qt6: found Q_OBJECT macro in '%s', moc'ing to '%s'" % (str(cpp), str(moc))) def __automoc_strategy_include_driven(self, env, moc_options, cpp, cpp_contents, out_sources): From d3c15fe621cb966e3119cce1b0861b05887b0d52 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 3 Dec 2023 11:17:39 +0100 Subject: [PATCH 07/17] linted --- nexxT/core/Thread.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index dcf2bb5..76ff103 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -11,10 +11,10 @@ import logging import sys import threading -from nexxT.Qt.QtCore import Qt, QObject, Signal, Slot, QCoreApplication, QThread +from nexxT.Qt.QtCore import QObject, Signal, Slot, QCoreApplication, QThread from nexxT.interface import FilterState, Services from nexxT.core.Exceptions import NodeExistsError, NexTInternalError, NodeNotFoundError, NexTRuntimeError -from nexxT.core.Utils import handleException, MethodInvoker +from nexxT.core.Utils import handleException logger = logging.getLogger(__name__) From 147abe9a42dac2d50f6c11779b14af94d728d365 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 3 Dec 2023 12:38:06 +0100 Subject: [PATCH 08/17] fix saving of composite filter variables --- nexxT/core/SubConfiguration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexxT/core/SubConfiguration.py b/nexxT/core/SubConfiguration.py index 3b1dfc0..9489b89 100644 --- a/nexxT/core/SubConfiguration.py +++ b/nexxT/core/SubConfiguration.py @@ -227,7 +227,7 @@ def adaptLibAndFactory(lib, factory): pass except PropertyCollectionPropertyNotFound: pass - variables = p.getVariables() + variables = mockup.propertyCollection().getVariables() if len(variables.keys()) > 0: ncfg["variables"] = {} for k in variables.keys(): From 43c0238d9665c9f691b8e50934169c9a99ee662a Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 3 Dec 2023 14:42:19 +0100 Subject: [PATCH 09/17] substitute also threadName make subConfigs removable via GUI --- nexxT/core/ActiveApplication.py | 1 + nexxT/core/ConfigFileSchema.json | 2 +- nexxT/core/Configuration.py | 30 +++++++++++++++++++++++++++++ nexxT/services/gui/Configuration.py | 12 ++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/nexxT/core/ActiveApplication.py b/nexxT/core/ActiveApplication.py index 967cff2..1da07ee 100644 --- a/nexxT/core/ActiveApplication.py +++ b/nexxT/core/ActiveApplication.py @@ -91,6 +91,7 @@ def _traverseAndSetup(self, graph, namePrefix="", variables=None): props = PropertyCollectionProxy(props, filterVars) nexTprops = props.getChildCollection("_nexxT") threadName = nexTprops.getProperty("thread") + threadName = props.getVariables().subst(threadName) if not threadName in self._threads: # create threads as needed self._threads[threadName] = NexTThread(threadName) diff --git a/nexxT/core/ConfigFileSchema.json b/nexxT/core/ConfigFileSchema.json index 4818be0..a766f05 100644 --- a/nexxT/core/ConfigFileSchema.json +++ b/nexxT/core/ConfigFileSchema.json @@ -80,7 +80,7 @@ "type": "string" }, "thread": { - "$ref": "#/definitions/identifier", + "type": "string", "default": "main" }, "dynamicInputPorts": { diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index cbd8f49..ca81b3d 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -347,6 +347,7 @@ def addNewApplication(self): def addNewCompositeFilter(self): """ Add a new composite filter to this configuration. The name will be chosen automaitcally to be unique. + :return: the chosen name """ name = "composite" @@ -357,6 +358,35 @@ def addNewCompositeFilter(self): CompositeFilter(name, self) return name + def removeSubConfig(self, subConfig): + """ + Remove the referenced sub configuration. + + :param subConfig: a SubConfiguration instance + """ + if subConfig in self._compositeFilters: + assert isinstance(subConfig, CompositeFilter) + # check whether this composite filter is referenced by any other subconfig + for sc in self._compositeFilters + self._applications: + if sc is subConfig: + continue + assert isinstance(sc, (Application, CompositeFilter)) + graph = sc.getGraph() + for n in graph.allNodes(): + mockup = graph.getMockup(n) + logger.info("class=%s lib=%s ff=%s name=%s", mockup.getPluginClass(), mockup.getLibrary(), mockup.getFactoryFunction(), n) + if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): + if mockup.getLibrary() is subConfig: + raise RuntimeError("Composite filter is still in use (%s)." % (sc.getName())) + self.setDirty() + self.subConfigRemoved.emit(subConfig.getName(), self.CONFIG_TYPE_COMPOSITE) + self._compositeFilters.remove(subConfig) + elif subConfig in self._applications: + self.setDirty() + self.subConfigRemoved.emit(subConfig.getName(), self.CONFIG_TYPE_APPLICATION) + self._applications.remove(subConfig) + raise RuntimeError("Cannot find sub config to remove") + def getApplicationNames(self): """ Return list of application names diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index b6646c2..e0c5dda 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -270,6 +270,9 @@ def _execTreeViewContextMenu(self, point): a1 = QAction("Edit graph") m.addAction(a1) a1.triggered.connect(lambda: self._addGraphView(item.subConfig)) + a1d5 = QAction("Remove %s ... " % ("app" if self.model.isApplication(index) else "composite")) + a1d5.triggered.connect(lambda: self._removeSubConfig(item.subConfig)) + m.addAction(a1d5) if self.model.isApplication(index): a2 = QAction("Select Application") a2.triggered.connect(lambda: self.changeActiveApp(self.model.data(index, Qt.DisplayRole))) @@ -339,6 +342,15 @@ def _execTreeViewContextMenu(self, point): del item.variables[item.name] return + def _removeSubConfig(self, subConfig): + ans = QMessageBox.question(self.mainWidget, "Confirm to remove", + "Do you really want to remove %s?" % subConfig.getName()) + if ans is QMessageBox.StandardButton.Yes: + try: + self._configuration.removeSubConfig(subConfig) + except RuntimeError as e: + QMessageBox.warning(self.mainWidget, "Warning", "Deletion failed: %s" % e) + def _configNameChanged(self, cfgfile): logger.debug("_configNameChanged: %s", cfgfile) assertMainThread() From 2f46277499d2d23b191a859782fa3b79d198d2de Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:07:27 +0100 Subject: [PATCH 10/17] linted --- nexxT/core/AppConsole.py | 1 - nexxT/core/Configuration.py | 3 +- nexxT/core/Graph.py | 1 - nexxT/core/PropertyCollectionImpl.py | 2 +- nexxT/core/Thread.py | 4 +- nexxT/core/Utils.py | 2 +- nexxT/core/Variables.py | 4 +- nexxT/services/SrvConfiguration.py | 8 +- nexxT/services/SrvPlaybackControl.py | 3 +- nexxT/services/SrvProfiling.py | 3 +- nexxT/services/gui/Configuration.py | 6 +- nexxT/services/gui/PropertyDelegate.py | 2 +- nexxT/tests/integration/test_gui.py.fixed | 1685 +++++++++++++++++++++ 13 files changed, 1701 insertions(+), 23 deletions(-) create mode 100644 nexxT/tests/integration/test_gui.py.fixed diff --git a/nexxT/core/AppConsole.py b/nexxT/core/AppConsole.py index e932448..4736a43 100644 --- a/nexxT/core/AppConsole.py +++ b/nexxT/core/AppConsole.py @@ -30,7 +30,6 @@ from nexxT.services.SrvConfiguration import MVCConfigurationBase from nexxT.services.SrvPlaybackControl import PlaybackControlConsole from nexxT.services.SrvRecordingControl import MVCRecordingControlBase -from nexxT.services.SrvProfiling import ProfilingService from nexxT.services.gui.GuiLogger import GuiLogger from nexxT.services.gui.MainWindow import MainWindow from nexxT.services.gui.Configuration import MVCConfigurationGUI diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index ca81b3d..5d0843e 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -374,10 +374,9 @@ def removeSubConfig(self, subConfig): graph = sc.getGraph() for n in graph.allNodes(): mockup = graph.getMockup(n) - logger.info("class=%s lib=%s ff=%s name=%s", mockup.getPluginClass(), mockup.getLibrary(), mockup.getFactoryFunction(), n) if issubclass(mockup.getPluginClass(), CompositeFilter.CompositeNode): if mockup.getLibrary() is subConfig: - raise RuntimeError("Composite filter is still in use (%s)." % (sc.getName())) + raise RuntimeError(f"Composite filter is still in use by {sc.getName()}.") self.setDirty() self.subConfigRemoved.emit(subConfig.getName(), self.CONFIG_TYPE_COMPOSITE) self._compositeFilters.remove(subConfig) diff --git a/nexxT/core/Graph.py b/nexxT/core/Graph.py index 14671f5..fae8ff5 100644 --- a/nexxT/core/Graph.py +++ b/nexxT/core/Graph.py @@ -16,7 +16,6 @@ from nexxT.core.PropertyCollectionImpl import PropertyCollectionImpl from nexxT.core.Utils import assertMainThread, handleException from nexxT.core.Exceptions import NexTRuntimeError, PropertyCollectionChildNotFound, CompositeRecursion -from nexxT.core.Variables import Variables logger = logging.getLogger(__name__) diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index 26b0b16..8da98fe 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -383,7 +383,7 @@ def __init__(self, proxiedPropColl, variables): proxiedPropColl.propertyChanged.connect(self._propertyChanged) self.setObjectName(self._proxiedPropColl.objectName()) - def _propertyChanged(self, propColl, name): + def _propertyChanged(self, _, name): self.propertyChanged.emit(self, name) def getChildCollection(self, name): diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index e4bfae3..b9744b1 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -11,10 +11,10 @@ import logging import sys import threading -from nexxT.Qt.QtCore import Qt, QObject, Signal, Slot, QCoreApplication, QThread +from nexxT.Qt.QtCore import QObject, Signal, Slot, QCoreApplication, QThread from nexxT.interface import FilterState, Services from nexxT.core.Exceptions import NodeExistsError, NexTInternalError, NodeNotFoundError, NexTRuntimeError -from nexxT.core.Utils import handleException, MethodInvoker +from nexxT.core.Utils import handleException logger = logging.getLogger(__name__) diff --git a/nexxT/core/Utils.py b/nexxT/core/Utils.py index 146b7d6..c82bddf 100644 --- a/nexxT/core/Utils.py +++ b/nexxT/core/Utils.py @@ -17,7 +17,7 @@ import sqlite3 import sys import time -from nexxT.Qt.QtCore import (QObject, Signal, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, +from nexxT.Qt.QtCore import (QObject, Slot, QMutex, QWaitCondition, QCoreApplication, QThread, QMutexLocker, QRecursiveMutex, QTimer, Qt, QPoint, QMetaObject) from nexxT.Qt.QtGui import QColor, QPainter, QTextLayout, QTextOption from nexxT.Qt.QtWidgets import QFrame, QSizePolicy diff --git a/nexxT/core/Variables.py b/nexxT/core/Variables.py index 6733fef..f812d5b 100644 --- a/nexxT/core/Variables.py +++ b/nexxT/core/Variables.py @@ -71,7 +71,7 @@ def getraw(self, key): def __setitem__(self, key, value): key = key.upper() - if key in self._variables._readonly and self.data[key] != value: + if self._variables.isReadonly(key) and self.data[key] != value: raise RuntimeError(f"Trying to modify readonly variable {key}.") self.data[key] = value self._variables.variableAddedOrChanged.emit(key, value) @@ -100,7 +100,7 @@ def copyAndReparent(self, newParent): res = Variables(newParent) for k in self.keys(): res[k] = self.getraw(k) - res._readonly = self._readonly.copy() + res.setReadonly(self._readonly) return res def setParent(self, parent): diff --git a/nexxT/services/SrvConfiguration.py b/nexxT/services/SrvConfiguration.py index cd08973..999a352 100644 --- a/nexxT/services/SrvConfiguration.py +++ b/nexxT/services/SrvConfiguration.py @@ -590,8 +590,7 @@ def data(self, index, role): # pylint: disable=too-many-return-statements,too-ma p = item.property.getPropertyDetails(item.name) if p.useEnvironment: return Qt.Checked - else: - return Qt.Unchecked + return Qt.Unchecked if role == Qt.DecorationRole: if index.column() != 0: return None @@ -725,13 +724,12 @@ def setData(self, index, value, role):# pylint: disable=too-many-return-statemen elif index.column() == 2: p = item.property.getPropertyDetails(item.name) if role == Qt.CheckStateRole: - value = False if Qt.CheckState(value) == Qt.Unchecked else True + value = not Qt.CheckState(value) == Qt.Unchecked if value and not p.useEnvironment: item.property.setVarProperty(item.name, str(item.property.getProperty(item.name))) elif not value and p.useEnvironment: item.property.setProperty(item.name, item.property.getProperty(item.name)) - else: - return False + return False i0 = self.index(index.row(), 1, index.parent()) i1 = self.index(index.row(), 2, index.parent()) self.dataChanged.emit(i0, i1, [Qt.DisplayRole, Qt.EditRole]) diff --git a/nexxT/services/SrvPlaybackControl.py b/nexxT/services/SrvPlaybackControl.py index 773f27d..d941887 100644 --- a/nexxT/services/SrvPlaybackControl.py +++ b/nexxT/services/SrvPlaybackControl.py @@ -279,8 +279,7 @@ def removeConnections(self, playbackDevice): if len(found) > 0: for devid, _ in found: del self._registeredDevices[devid] - for devid, dev in found: - del dev + del found logger.debug("disconnected connections of playback device. number of devices left: %d", len(self._registeredDevices)) MethodInvoker(dict(object=self, method="_updateFeatureSet", thread=mainThread()), Qt.QueuedConnection) diff --git a/nexxT/services/SrvProfiling.py b/nexxT/services/SrvProfiling.py index 6813d24..6783964 100644 --- a/nexxT/services/SrvProfiling.py +++ b/nexxT/services/SrvProfiling.py @@ -258,8 +258,7 @@ def deregisterThread(self): self._threadSpecificProfiling[t].timer.stop() todel.append(self._threadSpecificProfiling[t]) del self._threadSpecificProfiling[t] - for tsp in todel: - del tsp + del todel self.threadDeregistered.emit(t.objectName()) @Slot() diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index e0c5dda..39d9fcd 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -270,7 +270,7 @@ def _execTreeViewContextMenu(self, point): a1 = QAction("Edit graph") m.addAction(a1) a1.triggered.connect(lambda: self._addGraphView(item.subConfig)) - a1d5 = QAction("Remove %s ... " % ("app" if self.model.isApplication(index) else "composite")) + a1d5 = QAction(f"Remove {'app' if self.model.isApplication(index) else 'composite'} ... ") a1d5.triggered.connect(lambda: self._removeSubConfig(item.subConfig)) m.addAction(a1d5) if self.model.isApplication(index): @@ -344,12 +344,12 @@ def _execTreeViewContextMenu(self, point): def _removeSubConfig(self, subConfig): ans = QMessageBox.question(self.mainWidget, "Confirm to remove", - "Do you really want to remove %s?" % subConfig.getName()) + f"Do you really want to remove {subConfig.getName()}?") if ans is QMessageBox.StandardButton.Yes: try: self._configuration.removeSubConfig(subConfig) except RuntimeError as e: - QMessageBox.warning(self.mainWidget, "Warning", "Deletion failed: %s" % e) + QMessageBox.warning(self.mainWidget, "Warning", f"Deletion failed: {e}") def _configNameChanged(self, cfgfile): logger.debug("_configNameChanged: %s", cfgfile) diff --git a/nexxT/services/gui/PropertyDelegate.py b/nexxT/services/gui/PropertyDelegate.py index 4a40d03..9a6865b 100644 --- a/nexxT/services/gui/PropertyDelegate.py +++ b/nexxT/services/gui/PropertyDelegate.py @@ -9,7 +9,7 @@ """ from nexxT.Qt.QtCore import Qt -from nexxT.Qt.QtWidgets import QStyledItemDelegate, QCheckBox, QLineEdit +from nexxT.Qt.QtWidgets import QStyledItemDelegate, QLineEdit class PropertyDelegate(QStyledItemDelegate): """ diff --git a/nexxT/tests/integration/test_gui.py.fixed b/nexxT/tests/integration/test_gui.py.fixed new file mode 100644 index 0000000..bed200f --- /dev/null +++ b/nexxT/tests/integration/test_gui.py.fixed @@ -0,0 +1,1685 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2020 ifm electronic gmbh +# +# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# + +""" +Real gui testing, a handy command for monitoring what's going on in the headless mode is: + +> x11vnc -display :27 -localhost & (sleep 1; vncviewer :0) # 27 must match the display printed at the test start + +You can also pass --no-xvfb to pytest and also --keep-open for inspecting the issue in head mode. +""" + +import json +import os +import logging +from pathlib import Path +import re +import pytest +import nexxT.shiboken +from nexxT.Qt.QtCore import QItemSelection, Qt, QTimer, QSize, QPoint, QModelIndex +from nexxT.Qt.QtWidgets import QGraphicsSceneContextMenuEvent, QWidget, QApplication, QTreeView +from nexxT.core.AppConsole import startNexT +from nexxT.core import Compatibility +from nexxT.interface import Services +from nexxT.services.gui.GraphEditorView import GraphEditorView + +logger = logging.getLogger(__name__) + +@pytest.fixture +def keep_open(request): + return request.config.getoption("--keep-open") + +# context menu actions +class ContextMenuEntry(str): + pass +CM_ADD_APPLICATION = ContextMenuEntry("Add application") +CM_EDIT_GRAPH = ContextMenuEntry("Edit graph") +CM_INIT_APP = ContextMenuEntry("Init Application") +CM_INIT_APP_AND_OPEN =ContextMenuEntry("Init and load sequence") +CM_INIT_APP_AND_PLAY = ContextMenuEntry("Init, load and play") +CM_FILTER_LIBRARY = ContextMenuEntry("Filter Library") +CM_FILTER_FROM_PYMOD = ContextMenuEntry("Add filter from python module ...") +CM_FILTER_FROM_FILE = ContextMenuEntry("Add filter from file ...") +CM_FILTER_FROM_COMPOSITE = ContextMenuEntry("Add filter form composite definition ...") +CM_ADDCOMPOSITE = ContextMenuEntry("Add composite filter") +CM_AUTOLAYOUT = ContextMenuEntry("Auto layout") +CM_FILTER_LIBRARY_TESTS = ContextMenuEntry("tests") +CM_FILTER_LIBRARY_HARDDISK = ContextMenuEntry("harddisk") +CM_FILTER_LIBRARY_TESTS_NEXXT = ContextMenuEntry("nexxT") +CM_FILTER_LIBRARY_CSIMPLESOURCE = ContextMenuEntry("CSimpleSource") +CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER = ContextMenuEntry("PySimpleStaticFilter") +CM_FILTER_LIBRARY_PYSIMPLEVIEW = ContextMenuEntry("PySimpleView") +CM_FILTER_LIBRARY_HDF5WRITER = ContextMenuEntry("HDF5Writer") +CM_FILTER_LIBRARY_HDF5READER = ContextMenuEntry("HDF5Reader") +CM_RENAME_NODE = ContextMenuEntry("Rename node ...") +CM_REMOVE_NODE = ContextMenuEntry('Remove node ...') +CM_ADDDYNINPORT = ContextMenuEntry("Add dynamic input port ...") +CM_ADDDYNOUTPORT = ContextMenuEntry("Add dynamic output port ...") +CM_SUGGEST_DYNPORTS = ContextMenuEntry("Suggest dynamic ports ...") +CM_SETTHREAD = ContextMenuEntry("Set thread ...") +CM_RENAMEDYNPORT = ContextMenuEntry("Rename dynamic port ...") +CM_REMOVEDYNPORT = ContextMenuEntry("Remove dynamic port ...") +CONFIG_MENU_DEINITIALIZE = ContextMenuEntry("Deinitialize") +CONFIG_MENU_INITIALIZE = ContextMenuEntry("Initialize") +LM_WARNING = ContextMenuEntry("Warning") + +class GuiTestBase: + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + self.qtbot = qtbot + self.delay = delay + self.xvfb = xvfb + self.keep_open = keep_open + self.tmpdir = tmpdir + if xvfb is not None: + print("dims = ",xvfb.width, xvfb.height) + print("DISPLAY=",xvfb.display) + # make sure that we have a fresh environment + os.environ["HOME"] = str(tmpdir) + logger.info("TMPDIR=%s", tmpdir) + + """ + Class encapsulates useful method for gui testing the nexxT application. + """ + def activateContextMenu(self, *menuItems, **kwargs): + """ + In a given context menu navigate to the given index using key presses and activate it using return + :param menuItems: Might be either integers referencing the position in the menu or (better) strings referencing + the menu text + :return: + """ + def activeMenuEntry(): + """ + return the text of the active menu item including submenus + :return: + """ + menu = QApplication.activePopupWidget() + if menu is None: + return None + act = menu.activeAction() + if act is None: + return None + + while Compatibility.getMenuFromAction(act) is not None and Compatibility.getMenuFromAction(act).activeAction() is not None: + act = Compatibility.getMenuFromAction(act).activeAction() + return act.text() + + if kwargs.get("debug", False): + logger_debug = logger.info + else: + logger_debug = logger.debug + try: + # navigate to the requested menu item + for j in range(len(menuItems)): + if isinstance(menuItems[j], int): + for i in range(menuItems[j]): + self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) + logger_debug("(int) Current action: '%s'", activeMenuEntry()) + else: + nonNoneAction = None + while activeMenuEntry() is None or activeMenuEntry() != menuItems[j]: + logger_debug("(str) Current action: '%s' != '%s'", activeMenuEntry(), menuItems[j]) + self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) + if nonNoneAction is None: + nonNoneAction = activeMenuEntry() + else: + assert nonNoneAction != activeMenuEntry() + logger_debug("(str) Current action: '%s'", activeMenuEntry()) + if j < len(menuItems) - 1: + self.qtbot.keyClick(None, Qt.Key_Right, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + except Exception: + logger.exception("exception while activating context menu") + raise + + @staticmethod + def aw(): + """ + on xvfb, the main window sometimes looses focus leading to a crash of the qtbot's keyClick(s) function + this function avoids this + :return: + """ + w = QApplication.activeWindow() + if w is None: + QApplication.setActiveWindow(Services.getService("MainWindow").data()) + w = QApplication.activeWindow() + return w + + def enterText(self, text, w=None): + """ + Enter the given text into the widget w (or the current widget, if w is None) + :param text: the text to be entered + :param w: the widget it should be entered to + :return: + """ + if isinstance(text, str): + if text != "": + self.qtbot.keyClicks(w, text) + else: + for k in text: + self.qtbot.keyClick(w, k) + self.qtbot.keyClick(w, Qt.Key_Return) + + def gsContextMenu(self, graphView, pos): + """ + This function starts a context menu on a graphics view. + :param graphView: the respective QGraphicsView + :param pos: the position where the context menu shall be raised + :return: + """ + ev = QGraphicsSceneContextMenuEvent() + ev.setScenePos(pos) + ev.setPos(QPoint(0,0)) # item position + ev.setScreenPos(graphView.viewport().mapToGlobal(graphView.mapFromScene(pos))) + #print("scenePos=", ev.scenePos(), ", pos=", ev.pos(), ", screenPos=", ev.screenPos()) + self.qtbot.mouseMove(graphView.viewport(), graphView.mapFromScene(ev.scenePos())) + graphView.scene().contextMenuEvent(ev) + + def cmContextMenu(self, conf, idx, *contextMenuIndices, **kwargs): + """ + This function executes a context menu on the configuration tree view + :param conf: The configuration gui service + :param idx: A QModelIndex of the item where the context menu shall be raised + :param contextMenuIndices: A list of ContextMenuEntry, int and str instances. ContextMenuEntry and int instances + are used to navigate through the context menu, afterwards the str instances are + entered as text (in dialogs resulting from the context menu). + :return: + """ + treeView = conf.treeView + assert isinstance(treeView, QTreeView) + treeView.scrollTo(idx) + self.qtbot.wait(1000) + pos = treeView.visualRegionForSelection(QItemSelection(idx, idx)).boundingRect().center() + self.qtbot.mouseMove(treeView.viewport(), pos=pos, delay=self.delay) + try: + intIdx = max([i for i in range(-1, -len(contextMenuIndices)-1, -1) + if isinstance(contextMenuIndices[i], (int,ContextMenuEntry))]) + intIdx += len(contextMenuIndices) + except ValueError: + logger.exception("exception contextMenuIndices:%s empty?!?", contextMenuIndices) + intIdx = -1 + cmIdx = contextMenuIndices[:intIdx+1] + texts = contextMenuIndices[intIdx+1:] + if kwargs.get("debug", False): + logger.info("contextMenuIndices:%s cmIdx:%s texts:%s", contextMenuIndices, cmIdx, texts) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(*cmIdx, **kwargs)) + for i, t in enumerate(texts): + QTimer.singleShot(self.delay*(i+2), lambda text=t: self.enterText(text)) + conf._execTreeViewContextMenu(pos) + + def addNodeToGraphEditor(self, graphEditView, scenePos, *contextMenuItems): + """ + Adds a node to the nexxT graph editor. + :param graphEditView: the GraphEditorView instance + :param scenePos: the position where the node shall be created + :param contextMenuItems: the context menu items to be processed (see activateContextMenu(...)) + :return: the newly created node + """ + oldNodes = set(graphEditView.scene().nodes.keys()) + try: + intIdx = max([i for i in range(-1,-len(contextMenuItems)-1,-1) + if isinstance(contextMenuItems[i], (int, ContextMenuEntry))]) + intIdx += len(contextMenuItems) + except ValueError: + intIdx = -1 + cmIdx = contextMenuItems[:intIdx+1] + texts = contextMenuItems[intIdx+1:] + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(*cmIdx)) + for i,t in enumerate(texts): + QTimer.singleShot(self.delay*(i+2), lambda text=t: self.enterText(text)) + with self.qtbot.waitSignal(graphEditView.scene().changed): + self.gsContextMenu(graphEditView, scenePos) + res = None + assert len(graphEditView.scene().nodes) == len(oldNodes) + 1 + for n in graphEditView.scene().nodes: + if n not in oldNodes: + assert res is None + res = graphEditView.scene().nodes[n] + assert res is not None + # hover this item + scenePos = res.nodeGrItem.sceneBoundingRect().center() + self.qtbot.mouseMove(graphEditView.viewport(), QPoint(0,0), delay=self.delay) + self.qtbot.mouseMove(graphEditView.viewport(), graphEditView.mapFromScene(scenePos), delay=self.delay) + # set item selected and deselected again + self.qtbot.mouseClick(graphEditView.viewport(), Qt.LeftButton, pos=graphEditView.mapFromScene(scenePos), + delay=self.delay) + self.qtbot.mouseClick(graphEditView.viewport(), Qt.LeftButton, pos=graphEditView.mapFromScene(scenePos), + delay=self.delay) + return res + + def removeNodeFromGraph(self, graphEditView, node): + """ + Removes a node from the nexxT graph editor + :param graphEditView: the GraphEditorView instance + :param node: the node to be removed + :return: + """ + pos = node.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_REMOVE_NODE)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("")) + self.gsContextMenu(graphEditView, pos) + + def addConnectionToGraphEditor(self, graphEditView, p1, p2): + """ + Adds a connection in the nexxT graph editor + :param graphEditView: the GraphEditorView instance + :param p1: The port to start from + :param p2: The port to end to + :return: + """ + pos1 = graphEditView.mapFromScene(p1.portGrItem.sceneBoundingRect().center()) + pos2 = graphEditView.mapFromScene(p2.portGrItem.sceneBoundingRect().center()) + self.qtbot.mouseMove(graphEditView.viewport(), pos1, delay=self.delay) + self.qtbot.mousePress(graphEditView.viewport(), Qt.LeftButton, pos=pos1, delay=self.delay) + # mouse move event will not be triggered (yet?), see https://bugreports.qt.io/browse/QTBUG-5232 + for i in range(30): + w = i/29 + self.qtbot.mouseMove(graphEditView.viewport(), (pos1*(1-w)+pos2*w), delay=(self.delay+15)//30) + self.qtbot.mouseMove(graphEditView.viewport(), pos2, delay=self.delay) + self.qtbot.mouseRelease(graphEditView.viewport(), Qt.LeftButton, pos=pos2, delay=self.delay) + + def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expectedVal=None): + """ + Sets a filter property in the configuration gui service. + :param conf: the configuration gui service + :param subConfig: the SubConfiguration instance + :param filterName: the name of the filter + :param propName: the name of the property + :param propVal: the value of the property (which will be entered using enterText) + :param expectedVal: if not None, the new expected value after editing, otherwise propVal will be used as the + expected value. + :return: + """ + idxapp = conf.model.indexOfSubConfig(subConfig) + # search for filter + idxFilter = None + for r in range(conf.model.rowCount(idxapp)): + idxFilter = conf.model.index(r, 0, idxapp) + name = conf.model.data(idxFilter, Qt.DisplayRole) + if name == filterName: + break + else: + idxFilter = None + assert idxFilter is not None + # search for property + idxProp = None + row = None + for r in range(conf.model.rowCount(idxFilter)): + idxProp = conf.model.index(r, 0, idxFilter) + name = conf.model.data(idxProp, Qt.DisplayRole) + if name == propName: + row = r + break + else: + idxProp = None + assert idxProp is not None + assert row is not None + # start the editor by pressing F2 on the property value + idxPropVal = conf.model.index(row, 1, idxFilter) + conf.treeView.scrollTo(idxPropVal) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxPropVal, idxPropVal)) + self.qtbot.mouseMove(conf.treeView.viewport(), pos=region.boundingRect().center(), delay=self.delay) + self.qtbot.mouseClick(conf.treeView.viewport(), Qt.LeftButton, pos=region.boundingRect().center(), delay=self.delay) + self.qtbot.keyClick(conf.treeView.viewport(), Qt.Key_F2, delay=self.delay) + self.aw() + mw = Services.getService("MainWindow") + self.enterText(propVal, mw.findChild(QWidget, "PropertyDelegateEditor")) + self.qtbot.wait(self.delay) + if expectedVal is None: + expectedVal = propVal + assert conf.model.data(idxPropVal, Qt.DisplayRole) == expectedVal + + def getFilterProperty(self, conf, subConfig, filterName, propName): + """ + Sets a filter property in the configuration gui service. + :param conf: the configuration gui service + :param subConfig: the SubConfiguration instance + :param filterName: the name of the filter + :param propName: the name of the property + :return: the current property value + """ + idxapp = conf.model.indexOfSubConfig(subConfig) + # search for filter + idxFilter = None + for r in range(conf.model.rowCount(idxapp)): + idxFilter = conf.model.index(r, 0, idxapp) + name = conf.model.data(idxFilter, Qt.DisplayRole) + if name == filterName: + break + else: + idxFilter = None + assert idxFilter is not None + # search for property + idxProp = None + row = None + for r in range(conf.model.rowCount(idxFilter)): + idxProp = conf.model.index(r, 0, idxFilter) + name = conf.model.data(idxProp, Qt.DisplayRole) + if name == propName: + row = r + break + else: + idxProp = None + assert idxProp is not None + assert row is not None + # start the editor by pressing F2 on the property value + idxPropVal = conf.model.index(row, 1, idxFilter) + return conf.model.data(idxPropVal, Qt.DisplayRole) + + + def getLastLogFrameIdx(self, log): + """ + Convert the last received log line to a frame index (assuming that the PySimpleStaticFilter has been used) + Changed: this is an alias for getCurrentFrameIdx, since there might be some other log messages intervening + + :param log: the logging service + :return: the frame index + """ + self.qtbot.wait(1000) # log may be delayed + return self.getCurrentFrameIdx(log) + + @staticmethod + def getCurrentFrameIdx(log): + """ + Same as getLastLogFrameIdx but searches upwards + :param log: the logging service + :return: the frame index + """ + numRows = log.logWidget.model().rowCount(QModelIndex()) + for row in range(numRows-1,0,-1): + lidx = log.logWidget.model().index(row, 2, QModelIndex()) + lastmsg = log.logWidget.model().data(lidx, Qt.DisplayRole) + if "received: Sample" in lastmsg: + return int(lastmsg.strip().split(" ")[-1]) + + @staticmethod + def assertLogItem(log, expectedLevel, expectedMsg): + found = False + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + for row in range(numRows-1,0,-1): + level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + if level == expectedLevel and msg in expectedMsg: + found = True + if not found: + raise RuntimeError("expected message %s:%s not found in log", expectedLevel, expectedMsg) + + @staticmethod + def noWarningsInLog(log, ignore=[]): + """ + assert that there are no warnings logged + :param log: the logging service + :return: + """ + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + for row in range(numRows-1,0,-1): + level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) + if level not in ["INFO", "DEBUG", "INTERNAL"]: + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + if not msg in ignore: + raise RuntimeError("Warnings or errors found in log: %s(%s)", level, msg) + + def clickDiscardChanges(self): + """ + Discard the config changes if being asked to. + :return: + """ + self.qtbot.keyClick(None, Qt.Key_Tab, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + + def startGraphEditor(self, conf, mw, appName, isComposite=False): + """ + Start the graph editor of the given application. + :param conf: the configuration service + :param mw: the main window + :param appName: the name of the application to be edited + :param isComposite: if true, the name is related to a composite filter + :return: the graph editor view + """ + oldChildren = mw.findChildren(GraphEditorView, None) + if isComposite: + app = conf.configuration().compositeFilterByName(appName) + else: + app = conf.configuration().applicationByName(appName) + # start graph editor + self.cmContextMenu(conf, conf.model.indexOfSubConfig(app), 1) + newChildren = mw.findChildren(GraphEditorView, None) + gev = None + for w in newChildren: + if w not in oldChildren: + gev = w + gev.setMinimumSize(QSize(400, 350)) + return gev + + def select(self, graphEditView, nodes): + """ + Select the given nodes in the graph editor + :param graphEditView: The graph editor instances + :param nodes: the nodes to be selected + :return: + """ + pos = nodes[0].nodeGrItem.sceneBoundingRect().center() + self.qtbot.mouseClick(graphEditView.viewport(), Qt.LeftButton, pos=graphEditView.mapFromScene(pos), + delay=self.delay) + for node in nodes[1:]: + node.nodeGrItem.setSelected(True) + +class BasicTest(GuiTestBase): + """ + Concrete instance for the test_basic(...) test + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _first(self): + conf = None + mw = None + try: + mw = Services.getService("MainWindow") + mw.resize(1980,1080) + conf = Services.getService("Configuration") + rec = Services.getService("RecordingControl") + playback = Services.getService("PlaybackControl") + log = Services.getService("Logging") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create 3 nodes: CSimpleSource, PySimpleStaticFilter, HDF5Writer + n1 = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_CSIMPLESOURCE) + self.removeNodeFromGraph(gev, n1) + n1 = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_CSIMPLESOURCE) + n2 = self.addNodeToGraphEditor(gev, QPoint(20,80), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER) + n3 = self.addNodeToGraphEditor(gev, QPoint(20,140), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_HARDDISK, CM_FILTER_LIBRARY_HDF5WRITER) + n4 = self.addNodeToGraphEditor(gev, QPoint(-120,-60), CM_FILTER_FROM_PYMOD, + "nexxT.tests.interface.SimpleStaticFilter", "SimpleView") + n5 = self.addNodeToGraphEditor(gev, QPoint(-120, 140), CM_FILTER_FROM_PYMOD, + "nexxT.tests.interface.SimpleStaticFilter", "SimpleView") + n6 = self.addNodeToGraphEditor(gev, QPoint(20, -60), CM_FILTER_FROM_PYMOD, + "nexxT.tests.interface.SimpleStaticFilter", "SimpleView") + # auto layout + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_AUTOLAYOUT)) + self.gsContextMenu(gev, QPoint(-120,40)) + self.qtbot.wait(self.delay) + # rename n4 + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAME_NODE)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("view_source")) + #print(n4, n4.nodeGrItem.sceneBoundingRect().center()) + self.gsContextMenu(gev, n4.nodeGrItem.sceneBoundingRect().center()) + # rename n5 + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAME_NODE)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("view_filter")) + #print(n5, n5.nodeGrItem.sceneBoundingRect().center()) + self.gsContextMenu(gev, n5.nodeGrItem.sceneBoundingRect().center()) + # rename n6 + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAME_NODE)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("view_filter2")) + #print(n6, n6.nodeGrItem.sceneBoundingRect().center()) + self.gsContextMenu(gev, n6.nodeGrItem.sceneBoundingRect().center()) + # setup dynamic input port of HDF5Writer + n3p = n3.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("CSimpleSource_out")) + self.gsContextMenu(gev, n3p) + # rename the dynamic port + pp = n3.inPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAMEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("xxx")) + self.gsContextMenu(gev, pp) + # remove the dynamic port + pp = n3.inPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_REMOVEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("")) + self.gsContextMenu(gev, pp) + # setup dynamic input port of HDF5Writer + n3p = n3.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("CSimpleSource_out")) + self.gsContextMenu(gev, n3p) + # set thread of souurce + n1p = n1.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("source_thread")) + self.gsContextMenu(gev, n1p) + # set thread of HDF5Writer + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("writer_thread")) + self.gsContextMenu(gev, n3p) + # connect the ports + self.addConnectionToGraphEditor(gev, n1.outPortItems[0], n2.inPortItems[0]) + self.addConnectionToGraphEditor(gev, n3.inPortItems[0], n1.outPortItems[0]) + # set frequency to 10 + self.setFilterProperty(conf, app, "CSimpleSource", "frequency", "10.0") + # copy a part of the app to a composite filter + self.select(gev, [n1,n2]) + self.qtbot.keyClick(gev.viewport(), Qt.Key_X, Qt.ControlModifier, delay=self.delay) + # add composite + conf.treeView.scrollTo(idxComposites) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxComposites, idxComposites)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDCOMPOSITE)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + self.qtbot.wait(self.delay) + gevc = self.startGraphEditor(conf, mw, "composite", True) + assert gevc != gev + self.qtbot.wait(self.delay) + self.qtbot.keyClick(gevc.viewport(), Qt.Key_V, Qt.ControlModifier, delay=self.delay) + gevc_in = gevc.scene().nodes["CompositeInput"] + gevc_out = gevc.scene().nodes["CompositeOutput"] + n1 = gevc.scene().nodes["CSimpleSource"] + n2 = gevc.scene().nodes["PySimpleStaticFilter"] + # setup dynamic port of gevc_in + gevc_inp = gevc_in.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("comp_in")) + self.gsContextMenu(gevc, gevc_inp) + # setup dynamic ports of gevc_out + gevc_outp = gevc_out.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("source")) + self.gsContextMenu(gevc, gevc_outp) + gevc_outp = gevc_out.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("filter")) + self.gsContextMenu(gevc, gevc_outp) + # setup connections + self.addConnectionToGraphEditor(gevc, gevc_out.inPortItems[0], n1.outPortItems[0]) + self.addConnectionToGraphEditor(gevc, gevc_out.inPortItems[1], n2.outPortItems[0]) + # add composite filter to gev + comp = self.addNodeToGraphEditor(gev, QPoint(20,20), CM_FILTER_FROM_COMPOSITE, "composite") + nexxT.shiboken.delete(gevc.parent()) + self.qtbot.wait(self.delay) + # auto layout + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_AUTOLAYOUT)) + self.gsContextMenu(gev, QPoint(-120,40)) + self.qtbot.wait(self.delay) + self.addConnectionToGraphEditor(gev, comp.outPortItems[0], n3.inPortItems[0]) + # add visualization filters + self.addConnectionToGraphEditor(gev, comp.outPortItems[0], n4.inPortItems[0]) + self.addConnectionToGraphEditor(gev, comp.outPortItems[1], n5.inPortItems[0]) + self.addConnectionToGraphEditor(gev, comp.outPortItems[1], n6.inPortItems[0]) + # set captions + self.setFilterProperty(conf, app, "view_source", "caption", "view[0,0]") + self.setFilterProperty(conf, app, "view_filter", "caption", "view[1,0]") + self.setFilterProperty(conf, app, "view_filter2", "caption", "filter2") + # activate and initialize the application + with self.qtbot.waitSignal(conf.configuration().appActivated): + conf.configuration().activate("application") + self.aw() + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_INITIALIZE) + rec.dockWidget.raise_() + # application runs for 2 seconds + self.qtbot.wait(2000) + # set the folder for the recording service and start recording + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.tmpdir))) + rec.actSetDir.trigger() + recStartFrame = self.getCurrentFrameIdx(log) + rec.actStart.trigger() + # record for 2 seconds + self.qtbot.wait(2000) + # stop recording + recStopFrame = self.getCurrentFrameIdx(log) + rec.actStop.trigger() + assert recStopFrame >= recStartFrame + 10 + self.qtbot.wait(2000) + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + # check that the last log message is from the SimpleStaticFilter and it should have received more than 60 + # samples + assert self.getLastLogFrameIdx(log) >= 60 + # save the configuration file + prjfile = self.tmpdir / "test_project.json" + h5file = list(Path(self.tmpdir).glob("*.h5")) + assert len(h5file) == 1 + h5file = h5file[0] + QTimer.singleShot(self.delay, lambda: self.enterText(str(prjfile))) + conf.actSave.trigger() + gevc = self.startGraphEditor(conf, mw, "composite", True) + self.removeNodeFromGraph(gevc, gevc.scene().nodes["PySimpleStaticFilter"]) + # load the confiugration file + assert conf.configuration().dirty() + QTimer.singleShot(self.delay, lambda: self.clickDiscardChanges()) + QTimer.singleShot(2*self.delay, lambda: self.enterText(str(prjfile))) + conf.actLoad.trigger() + + # add another application for offline use + conf.configuration().addNewApplication() + # start and delete a graph editor for the old application + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + nexxT.shiboken.delete(gev.parent()) + self.qtbot.wait(self.delay) + # start the editor for the new application + gev = self.startGraphEditor(conf, mw, "application_2") + # start graph editor + self.qtbot.mouseMove(gev, pos=QPoint(20,20), delay=self.delay) + # create 2 nodes: HDF5Reader and PySimpleStaticFilter + n1 = self.addNodeToGraphEditor(gev, QPoint(20,80), CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_HARDDISK, + CM_FILTER_LIBRARY_HDF5READER) + n2 = self.addNodeToGraphEditor(gev, QPoint(20,80), CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER) + # auto layout + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_AUTOLAYOUT)) + self.gsContextMenu(gev, QPoint(1,1)) + # setup dynamic output port of HDF5Reader + n1p = n1.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("yyy")) + self.gsContextMenu(gev, n1p) + # rename the dynamic port + pp = n1.outPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_RENAMEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("xxx")) + self.gsContextMenu(gev, pp) + self.qtbot.wait(self.delay) + # remove the dynamic port + pp = n1.outPortItems[0].portGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_REMOVEDYNPORT)) + QTimer.singleShot(2*self.delay, lambda: self.enterText("")) + self.gsContextMenu(gev, pp) + # setup dynamic ports of HDF5Reader using the suggest ports feature + n1p = n1.nodeGrItem.sceneBoundingRect().center() + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SUGGEST_DYNPORTS)) + QTimer.singleShot(self.delay*2, lambda: self.enterText(str(h5file))) + QTimer.singleShot(self.delay*4, lambda: self.enterText("")) + self.gsContextMenu(gev, n1p) + # set thread of HDF5Writer + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_SETTHREAD)) + QTimer.singleShot(self.delay*2, lambda: self.enterText("reader_thread")) + self.gsContextMenu(gev, n1p) + # connect the ports + self.addConnectionToGraphEditor(gev, n1.outPortItems[0], n2.inPortItems[0]) + # activate and initialize the application + with self.qtbot.waitSignal(conf.configuration().appActivated): + conf.configuration().activate("application_2") + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_INITIALIZE) + # turn off load monitoring + self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.qtbot.wait(self.delay) + # turn on load monitoring + self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.qtbot.wait(self.delay) + # turn on port profiling + self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.qtbot.wait(self.delay) + # select file in browser + playback.dockWidget.raise_() + self.qtbot.keyClick(playback.browser._lineedit, Qt.Key_A, Qt.ControlModifier) + self.qtbot.keyClicks(playback.browser._lineedit, str(h5file)) + self.qtbot.keyClick(playback.browser._lineedit, Qt.Key_Return) + # wait until action start is enabled + self.qtbot.waitUntil(playback.actStart.isEnabled) + # play until finished + playback.actStart.trigger() + self.qtbot.waitUntil(lambda: not playback.actStart.isEnabled()) + self.qtbot.waitUntil(lambda: not playback.actPause.isEnabled(), timeout=10000) + # check that the last log message is from the SimpleStaticFilter and it should be in the range of 40-50 + lastFrame = self.getLastLogFrameIdx(log) + assert recStopFrame-10 <= lastFrame <= recStopFrame+10 + playback.actStepBwd.trigger() + self.qtbot.wait(self.delay) + currFrame = self.getLastLogFrameIdx(log) + assert currFrame == lastFrame - 1 + playback.actStepFwd.trigger() + self.qtbot.wait(self.delay) + assert self.getLastLogFrameIdx(log) == lastFrame + playback.actSeekBegin.trigger() + firstFrame = self.getLastLogFrameIdx(log) + assert recStartFrame-10 <= firstFrame <= recStartFrame+10 + playback.actSeekEnd.trigger() + assert self.getLastLogFrameIdx(log) == lastFrame + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + + conf.actSave.trigger() + self.qtbot.wait(1000) + self.noWarningsInLog(log) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _second(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + playback = Services.getService("PlaybackControl") + log = Services.getService("Logging") + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application_2")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + assert not playback.actPause.isEnabled() + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_OPEN, 0) + self.qtbot.wait(1000) + assert not playback.actPause.isEnabled() + playback.actStepFwd.trigger() + self.qtbot.wait(1000) + firstFrame = self.getLastLogFrameIdx(log) + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) + self.qtbot.wait(1000) + self.qtbot.waitUntil(playback.actStart.isEnabled, timeout=10000) + lastFrame = self.getLastLogFrameIdx(log) + assert lastFrame >= firstFrame + 10 + # this is the online config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(2000) + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_OPEN, 0) + self.qtbot.wait(2000) + self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) + self.qtbot.wait(2000) + self.noWarningsInLog(log, ignore=[ + "did not find a playback device taking control", + "The inter-thread connection is set to stopped mode; data sample discarded.", + "operation stop happening during receiveAsync's processEvents. This shouldn't be happening."]) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test_first(self): + """ + first start of nexxT in a clean environment, click through a pretty exhaustive scenario. + :return: + """ + QTimer.singleShot(self.delay, self._first) + startNexT(None, None, [], [], True) + + def test_second(self): + """ + second start of nexxT, make sure that the history is saved correctly + :return: + """ + QTimer.singleShot(self.delay, self._second) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", ["rand"]) +def test_basic(qtbot, xvfb, keep_open, delay, tmpdir): + if delay == "rand": + import random + delay = random.randint(100, 300) + test = BasicTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_first() + test.test_second() + +class PropertyTest(GuiTestBase): + """ + Concrete test class for the test_property test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _properties(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thefilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter + +class TheFilter(Filter): + def __init__(self, env): + super().__init__(False, False, env) + pc = self.propertyCollection() + pc.defineProperty("bool_prop", False, "a boolean") + pc.defineProperty("unbound_float", 7., "an unbound float") + pc.defineProperty("low_bound_float", 7., "a low bound float", dict(min=-3)) + pc.defineProperty("high_bound_float", 7., "a high bound float", dict(max=123)) + pc.defineProperty("bound_float", 7., "a bound float", dict(min=6, max=1203)) + pc.defineProperty("unbound_int", 7, "an unbound integer") + pc.defineProperty("low_bound_int", 7, "a low bound integer", dict(min=-3)) + pc.defineProperty("high_bound_int", 7, "a high bound integer", dict(max=123)) + pc.defineProperty("bound_int", 7, "a bound integer", dict(min=6, max=1203)) + pc.defineProperty("string", "str", "an arbitrary string") + pc.defineProperty("enum", "v1", "an enum", dict(enum=["v1", "v2", "v3"])) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + logger.info("Filter: %s", repr(the_filter)) + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Up, Qt.Key_Return], "False") + self.setFilterProperty(conf, app, "TheFilter", "bool_prop", [Qt.Key_Up, Qt.Key_Return], "False") + self.setFilterProperty(conf, app, "TheFilter", "unbound_float", "3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "unbound_float", "-3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "-3.4028235e+38", "-3.0") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "-4", "-3.0") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_float", "-3", "-3.0") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "-3.4028235e+38") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "3.4028235e+38", "123.0") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "124", "123.0") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_float", "123", "123.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "-9", "6.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "5", "6.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "6.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "1204", "1203.0") + self.setFilterProperty(conf, app, "TheFilter", "bound_float", "1203", "1203.0") + self.setFilterProperty(conf, app, "TheFilter", "unbound_int", "2147483647") + self.setFilterProperty(conf, app, "TheFilter", "unbound_int", "-2147483648") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "2147483647") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "-2147483648", "-2") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "-4", "-2") + self.setFilterProperty(conf, app, "TheFilter", "low_bound_int", "-3") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "-2147483648") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "2147483647", "21") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "124", "12") + self.setFilterProperty(conf, app, "TheFilter", "high_bound_int", "123") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "-9", "9") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "5", "9") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "6") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "1204", "120") + self.setFilterProperty(conf, app, "TheFilter", "bound_int", "1203") + self.setFilterProperty(conf, app, "TheFilter", "string", "", "str") + self.setFilterProperty(conf, app, "TheFilter", "string", [Qt.Key_Backspace], "") + self.setFilterProperty(conf, app, "TheFilter", "string", "an arbitrary value") + # the enum editor is a combo box, so the text editing does not work here. + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Down, Qt.Key_Return], "v2") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Down, Qt.Key_Return], "v3") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Down, Qt.Key_Return], "v3") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Up, Qt.Key_Return], "v2") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Up, Qt.Key_Return], "v1") + self.setFilterProperty(conf, app, "TheFilter", "enum", [Qt.Key_Up, Qt.Key_Return], "v1") + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _dynamic_properties(self): + conf = None + mw = None + thefilter_py = (Path(self.tmpdir) / "thedynfilter.py") + thefilter_py.write_text( +""" +from nexxT.interface import Filter + +class TheDynFilter(Filter): + def __init__(self, env): + super().__init__(True, True, env) + + def onInit(self): + pc = self.propertyCollection() + din = self.getDynamicInputPorts() + dout = self.getDynamicOutputPorts() + pc.defineProperty("enum_input_ports", "(none)", "help", + dict(enum=["(none)"] +[p.name() for p in din], ignoreInconsistentOptions=True)) + pc.defineProperty("enum_output_ports", "(none)", "help", + dict(enum=["(none)"] +[p.name() for p in dout], ignoreInconsistentOptions=True)) +""" + ) + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxComposites = conf.model.index(0, 0) + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300, 300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a node "TheFilter" + the_filter = self.addNodeToGraphEditor(gev, QPoint(20, 20), + CM_FILTER_FROM_FILE, str(thefilter_py), "TheDynFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("input_port_1")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNINPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("input_port_2")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADDDYNOUTPORT)) + QTimer.singleShot(self.delay * 2, lambda: self.enterText("output_port")) + self.gsContextMenu(gev, the_filter.nodeGrItem.sceneBoundingRect().center()) + + logger.info("Filter: %s", repr(the_filter)) + self.setFilterProperty(conf, app, "TheDynFilter", "enum_input_ports", + [Qt.Key_Down, Qt.Key_Return], "input_port_1") + self.setFilterProperty(conf, app, "TheDynFilter", "enum_input_ports", + [Qt.Key_Down, Qt.Key_Return], "input_port_2") + self.setFilterProperty(conf, app, "TheDynFilter", "enum_output_ports", + [Qt.Key_Down, Qt.Key_Return], "output_port") + + cfgfile = str((Path(self.tmpdir) / "dynprops.json").absolute()) + QTimer.singleShot(self.delay, lambda: self.enterText(cfgfile)) + conf.actSave.trigger() + + logger.info("saved application") + + QTimer.singleShot(self.delay, lambda: self.enterText(cfgfile)) + conf.actLoad.trigger() + + logger.info("loaded application") + app = conf.configuration().applicationByName("application") + assert self.getFilterProperty(conf, app, "TheDynFilter", "enum_input_ports") == "input_port_2" + assert self.getFilterProperty(conf, app, "TheDynFilter", "enum_output_ports") == "output_port" + + + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._properties) + startNexT(None, None, [], [], True) + + def test_dyn(self): + """ + test property editing in config editor + :return: + """ + QTimer.singleShot(self.delay, self._dynamic_properties) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_properties(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_dyn_properties(qtbot, xvfb, keep_open, delay, tmpdir): + test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test_dyn() + +class GuiStateTest(GuiTestBase): + """ + Concrete test class for the guistate test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.prjfile = self.tmpdir / "test_guistate.json" + self.guistatefile = self.tmpdir / "test_guistate.json.guistate" + + def getMdiWindow(self): + mw = Services.getService("MainWindow") + assert len(mw.managedSubplots) == 1 + title = list(mw.managedSubplots.keys())[0] + return mw.managedSubplots[title]["mdiSubWindow"] + + def _stage0(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a visualization node + pysimpleview = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_PYSIMPLEVIEW) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + # save the configuration file + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.prjfile))) + conf.actSave.trigger() + self.prjfile_contents = self.prjfile.read_text("utf-8") + assert not self.guistatefile.exists() + # initialize the application, window is shown the first time + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + self.getMdiWindow().move(QPoint(37, 63)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + assert not self.guistatefile.exists() + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage1(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + assert self.mdigeom == self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage2(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to default location + assert self.mdigeom != self.getMdiWindow().geometry() + self.getMdiWindow().move(QPoint(42, 51)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + # because the gui state is not correctly saved when an application is active, the action is disabled in + # active state + assert not conf.actSaveWithGuiState.isEnabled() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.qtbot.wait(self.delay) + # action should be enabled in non-active state + assert conf.actSaveWithGuiState.isEnabled() + conf.actSaveWithGuiState.trigger() + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage3(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to default location + self.getMdiWindow().move(QPoint(17, 22)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + #self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + #self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + # reload the config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to last location + assert self.mdigeom == self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + first start of nexxT in a clean environment + :return: + """ + # create application and move window to non-default location + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + assert self.guistatefile.exists() + self.guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", self.guistate_contents) + + # assert that the window is in the non-default location + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # remove gui state -> the window should be placed in default location + os.remove(str(self.guistatefile)) + # assert that window is in default location and save the gui state to the config file + QTimer.singleShot(self.delay, self._stage2) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents != guistate_contents + self.guistate_contents = guistate_contents + + # assert that the window is in the non-default location + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # remove gui state -> the window should still be placed in non-default location + os.remove(str(self.guistatefile)) + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # check that re-opening the same config correctly restores the gui state + QTimer.singleShot(self.delay, self._stage3) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_guistate(qtbot, xvfb, keep_open, delay, tmpdir): + test = GuiStateTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() + +class DeadlockTestIssue25(GuiTestBase): + """ + Concrete test class for the test_property test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir, change_conn): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.change_conn = change_conn + self.tmpdir = tmpdir + + def _stage0(self): + tmpdir = self.tmpdir + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + # load recent config + if self.change_conn is None: + fn = str((Path(__file__).parent.parent / "core" / "test_deadlock.json").absolute()) + else: + cfg = json.load((Path(__file__).parent.parent / "core" / "test_deadlock.json").open("rb")) + conns = cfg["applications"][0]["connections"] + conns = conns[:self.change_conn] + conns[self.change_conn+1:] + cfg["applications"][0]["connections"] = conns + fn = Path(tmpdir) / "test_deadlock.json" + with fn.open("w") as fp: + json.dump(cfg, fp) + fn = str(fn.absolute()) + logger.info("laoding fn=%s", fn) + QTimer.singleShot(self.delay, lambda: self.enterText(fn)) + conf.actLoad.trigger() + self.qtbot.wait(self.delay*2) + + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("deadlock")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + if self.change_conn in [None, 0, 2]: + self.qtbot.wait(1000) + logMsg = "This graph is not deadlock-safe. A cycle has been found in the thread graph: main->compute->main" + self.noWarningsInLog(log, ignore=[logMsg]) + self.assertLogItem(log, "ERROR", logMsg) + else: + self.qtbot.wait(10000) + self.noWarningsInLog(log) + + # assert that the samples arrived in the correct order + def assertSampleOrder(): + numRows = log.logWidget.model().rowCount(QModelIndex()) + filters = {} + for row in range(numRows): + lidx = log.logWidget.model().index(row, 2, QModelIndex()) + msg = log.logWidget.model().data(lidx, Qt.DisplayRole) + if "received: Sample" in msg: + flt = msg.split(":")[0] + idx = int(msg.strip().split(" ")[-1]) + if not flt in filters: + assert idx == 1 + else: + assert idx == filters[flt] + 1 + filters[flt] = idx + # at the moment we do not let the user start these configs, but the sample order is still ok with no samples + # at all + assertSampleOrder() + logger.info("finishing") + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + self.qtbot.wait(1000) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +@pytest.mark.timeout(60, method="thread") +@pytest.mark.parametrize("change_conn", [None, 0, 1, 2, 3]) +def test_deadlock_issue25(qtbot, xvfb, keep_open, delay, tmpdir, change_conn): + test = DeadlockTestIssue25(qtbot, xvfb, keep_open, delay, tmpdir, change_conn) + test.test() + +class ExecutionOrderTest(GuiTestBase): + """ + Concrete test class for the test_property test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + + def _stage0(self): + # binary tree execution order + conf = None + mw = None + try: + # execution order config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("binarytree")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(3000) + log = Services.getService("Logging") + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + # depth first execution order + expected = [(1,1), (2,1), (2,2), (1,2), (2,3), (2,4)] + order = [] + for row in range(numRows): + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + M = re.match(r'^layer(\d)_f(\d)', msg) + if M is not None: + item = (int(M.group(1)),int(M.group(2))) + order.append( item ) + for i, item in enumerate(order): + assert item == expected[i % len(expected)] + assert len(order) >= len(expected) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage1(self): + # recursion single thread execution order + conf = None + mw = None + try: + # execution order config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("recursion_single_thread")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(3000) + log = Services.getService("Logging") + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + expected = [(1, "recursive", "in"), (1, "filter", None), (1, "recursive", "recursive")] + order = [] + for row in range(numRows): + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + M = re.match(r'^([^:]+):received: Sample (\d+)(.*)', msg) + if M is not None: + M2 = re.match(r" on port (.*)$", M.group(3)) + item = (int(M.group(2)), M.group(1), M2.group(1) if M2 is not None else None) + order.append( item ) + k = 0 + for i, item in enumerate(order): + if i % len(expected) == 0: + k += 1 + eitem = expected[i % len(expected)] + eitem = (k,) + eitem[1:] + assert item == eitem + assert len(order) >= len(expected) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage2(self): + self._throughput("singlethread") + + def _stage3(self): + self._throughput("multithread") + + def _throughput(self, threadmode): + # throughput + conf = None + mw = None + try: + # execution order config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + + app = conf.configuration().applicationByName("binarytree") + # start graph editor + self.setFilterProperty(conf, app, "layer1_f1", "log_throughput_at_end", [Qt.Key_Down, Qt.Key_Return], "True") + self.setFilterProperty(conf, app, "source", "frequency", "10000.0") + + if threadmode == "multithread": + graph = app.getGraph() + for n in graph.allNodes(): + mockup = graph.getMockup(n) + pc = mockup.getPropertyCollectionImpl() + pc.children()[0].setProperty("thread", "thread_"+n) + + self.qtbot.keyClick(self.aw(), Qt.Key_L, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(LM_WARNING) + # this is the offline config + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("binarytree")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(3000) + log = Services.getService("Logging") + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.qtbot.wait(2*self.delay) + + model = log.logWidget.model() + numRows = model.rowCount(QModelIndex()) + throughput = None + for row in range(numRows): + msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) + logger.info(repr(msg)) + M = re.match(r'layer1_f1:throughput: (\d+.\d+) samples/second', msg) + if M is not None: + throughput = float(M.group(1)) + if threadmode == "multithread": + self.record_property("multithread_smp_per_sec", throughput) + assert throughput >= 750 + else: + self.record_property("throughput_smp_per_sec", throughput) + assert throughput >= 4500 + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self, record_property): + """ + test property editing in config editor + :return: + """ + self.record_property = record_property + QTimer.singleShot(self.delay, self._stage0) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + QTimer.singleShot(self.delay, self._stage1) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + QTimer.singleShot(self.delay, self._stage2) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + QTimer.singleShot(self.delay, self._stage3) + startNexT(str(Path(__file__).parent.parent / "core" / "test_tree_order.json"), None, [], [], True) + + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_executionOrder(qtbot, xvfb, keep_open, delay, tmpdir, record_property): + test = ExecutionOrderTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test(record_property) + +class ReloadTest(GuiTestBase): + """ + Concrete test class for the guistate test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.prjfile = self.tmpdir / "test_reload.json" + self.guistatefile = self.tmpdir / "test_reload.json.guistate" + + def generateFilter(self, messageAtOpen): + myfilter = self.tmpdir / "myfilter.py" + myfilter.write_text(""" +import logging +from nexxT.Qt.QtWidgets import QLabel +from nexxT.interface import Filter, InputPort, Services + +logger = logging.getLogger(__name__) + +class MyFilter(Filter): # almost same as SimpleView + def __init__(self, env): + super().__init__(False, False, env) + self.inputPort = InputPort(False, "in", env) + self.addStaticPort(self.inputPort) + self.propertyCollection().defineProperty("caption", "view", "Caption of view window.") + self.label = None + + def onOpen(self): + caption = self.propertyCollection().getProperty("caption") + mw = Services.getService("MainWindow") + self.label = QLabel() + self.label.setMinimumSize(100, 20) + mw.subplot(caption, self, self.label) + logger.info("%s") + + def onPortDataChanged(self, inputPort): + dataSample = inputPort.getData() + if dataSample.getDatatype() == "text/utf8": + self.label.setText(dataSample.getContent().data().decode("utf8")) + + def onClose(self): + mw = Services.getService("MainWindow") + mw.releaseSubplot(self.label) + self.label = None +""" % messageAtOpen, encoding="utf-8") + return Path(myfilter) + + def getMdiWindow(self): + mw = Services.getService("MainWindow") + assert len(mw.managedSubplots) == 1 + title = list(mw.managedSubplots.keys())[0] + return mw.managedSubplots[title]["mdiSubWindow"] + + def _stage0(self): + conf = None + mw = None + try: + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + log = Services.getService("Logging") + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a visualization node + pyfile = str(self.generateFilter("myfilter version 1").absolute()) + logger.info("pyfile=%s", pyfile) + pysimpleview = self.addNodeToGraphEditor(gev, QPoint(20,20), CM_FILTER_FROM_FILE, + pyfile, "MyFilter") + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + # init application + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # note: the following depends on --forked isolation which is broken with PySide6 + self.assertLogItem(log, "INFO", "myfilter version 1") + # move the window to non-standard position + self.getMdiWindow().move(QPoint(37, 63)) + self.qtbot.wait(1000) + mdigeom = self.getMdiWindow().geometry() + # generate new filter version + self.generateFilter("myfilter version 2") + # reload (without config being saved, no gui state) + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + assert mdigeom == self.getMdiWindow().geometry() + self.assertLogItem(log, "INFO", "myfilter version 2") + # save the configuration file + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.prjfile))) + conf.actSave.trigger() + self.prjfile_contents = self.prjfile.read_text("utf-8") + assert not self.guistatefile.exists() + # move window + self.getMdiWindow().move(QPoint(31, 23)) + self.qtbot.wait(1000) + mdigeom = self.getMdiWindow().geometry() + # generate new filter version + self.generateFilter("myfilter version 3") + # reload (without config being saved, no gui state) + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + assert mdigeom == self.getMdiWindow().geometry() + self.assertLogItem(log, "INFO", "myfilter version 3") + # save again to create gui state + # can't save again without setting config to dirty + conf._dirtyChanged(True) + assert conf.actSave.isEnabled() + conf.actSave.trigger() + assert self.guistatefile.exists() + # move window + self.getMdiWindow().move(QPoint(17, 11)) + self.qtbot.wait(1000) + mdigeom = self.getMdiWindow().geometry() + # generate new filter version + self.generateFilter("myfilter version 4") + # reload (without config being saved, no gui state) + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + assert mdigeom == self.getMdiWindow().geometry() + self.assertLogItem(log, "INFO", "myfilter version 4") + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.generateFilter("myfilter version 5") + # reload + self.qtbot.keyClick(self.aw(), Qt.Key_P, Qt.ControlModifier, delay=self.delay) + self.qtbot.wait(1000) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + self.assertLogItem(log, "INFO", "myfilter version 5") + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + first start of nexxT in a clean environment + :return: + """ + # create application and move window to non-default location + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_reload(qtbot, xvfb, keep_open, delay, tmpdir): + test = ReloadTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() From 9acc2876ee9712b6161f2cd2e0d63e078b638f88 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Mon, 4 Dec 2023 21:53:48 +0100 Subject: [PATCH 11/17] removeSubConfig: add tests and fix remaining issues --- nexxT/core/Configuration.py | 3 +- nexxT/services/gui/Configuration.py | 2 +- nexxT/tests/integration/test_gui.py | 44 +++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/nexxT/core/Configuration.py b/nexxT/core/Configuration.py index 5d0843e..5bb7428 100644 --- a/nexxT/core/Configuration.py +++ b/nexxT/core/Configuration.py @@ -384,7 +384,8 @@ def removeSubConfig(self, subConfig): self.setDirty() self.subConfigRemoved.emit(subConfig.getName(), self.CONFIG_TYPE_APPLICATION) self._applications.remove(subConfig) - raise RuntimeError("Cannot find sub config to remove") + else: + raise RuntimeError(f"Cannot find sub config {subConfig} to remove") def getApplicationNames(self): """ diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index 39d9fcd..f634327 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -270,7 +270,7 @@ def _execTreeViewContextMenu(self, point): a1 = QAction("Edit graph") m.addAction(a1) a1.triggered.connect(lambda: self._addGraphView(item.subConfig)) - a1d5 = QAction(f"Remove {'app' if self.model.isApplication(index) else 'composite'} ... ") + a1d5 = QAction(f"Remove {'app' if self.model.isApplication(index) else 'composite'} ...") a1d5.triggered.connect(lambda: self._removeSubConfig(item.subConfig)) m.addAction(a1d5) if self.model.isApplication(index): diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index b29c7f8..f797d32 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -37,6 +37,8 @@ class ContextMenuEntry(str): pass CM_ADD_APPLICATION = ContextMenuEntry("Add application") CM_EDIT_GRAPH = ContextMenuEntry("Edit graph") +CM_REMOVE_APP = ContextMenuEntry("Remove app ...") +CM_REMOVE_COMPOSITE = ContextMenuEntry("Remove composite ...") CM_INIT_APP = ContextMenuEntry("Init Application") CM_INIT_APP_AND_OPEN =ContextMenuEntry("Init and load sequence") CM_INIT_APP_AND_PLAY = ContextMenuEntry("Init, load and play") @@ -774,9 +776,39 @@ def _first(self): # de-initialize application self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) - conf.actSave.trigger() - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) + # test removal of subconfigs which are still in use + compidx = conf.model.indexOfSubConfig(conf.configuration().compositeFilterByName("composite")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, compidx, CM_REMOVE_COMPOSITE, "", "")) + self.qtbot.wait(self.delay * 5) + assert conf.model.indexOfSubConfig(conf.configuration().compositeFilterByName("composite")) != QModelIndex() + # test removal of applications + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, appidx, CM_REMOVE_APP, "")) + self.qtbot.wait(self.delay * 4) + try: + conf.configuration().applicationByName("application") + assert False + except: + pass + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application_2")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, appidx, CM_REMOVE_APP, "")) + self.qtbot.wait(self.delay * 4) + try: + conf.configuration().applicationByName("application_2") + assert False + except: + pass + compidx = conf.model.indexOfSubConfig(conf.configuration().compositeFilterByName("composite")) + QTimer.singleShot(self.delay, lambda: self.cmContextMenu(conf, compidx, CM_REMOVE_COMPOSITE, "")) + self.qtbot.wait(self.delay * 4) + try: + conf.configuration().compositeFilterByName("composite") + assert False + except: + pass + self.noWarningsInLog(log) finally: if not self.keep_open: @@ -798,16 +830,16 @@ def _second(self): # this is the offline config appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application_2")) self.cmContextMenu(conf, appidx, CM_INIT_APP) - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) assert not playback.actPause.isEnabled() self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_OPEN, 0) - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) assert not playback.actPause.isEnabled() playback.actStepFwd.trigger() - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) firstFrame = self.getLastLogFrameIdx(log) self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) - self.qtbot.wait(1000) + self.qtbot.wait(self.delay) self.qtbot.waitUntil(playback.actStart.isEnabled, timeout=10000) lastFrame = self.getLastLogFrameIdx(log) assert lastFrame >= firstFrame + 10 From 7649fa6928a6bff46d4c560ead17e86582ee765b Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Mon, 4 Dec 2023 22:49:01 +0100 Subject: [PATCH 12/17] add new arguments --single-threaded and --disable-update-heuristics and corresponding arguments for startNexT avoid unexpected runtime error when new samples arrive in STOPPING state and discard the sample instead fix errors in ImageData (sometimes the camera grabber seems to return invalid data) --- nexxT/core/ActiveApplication.py | 6 +++++- nexxT/core/AppConsole.py | 19 +++++++++++++++---- nexxT/core/PluginManager.py | 4 ++++ nexxT/examples/framework/ImageData.py | 2 +- nexxT/src/FilterEnvironment.cpp | 1 + 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/nexxT/core/ActiveApplication.py b/nexxT/core/ActiveApplication.py index c88a647..e9428d4 100644 --- a/nexxT/core/ActiveApplication.py +++ b/nexxT/core/ActiveApplication.py @@ -28,6 +28,8 @@ class ActiveApplication(QObject): stateChanged = Signal(int) # Signal is emitted after the state of the graph has been changed aboutToClose = Signal() # Signal is emitted before stop operation takes place + singleThreaded = False + def __init__(self, graph): super().__init__() assertMainThread() @@ -78,7 +80,9 @@ def _traverseAndSetup(self, graph, namePrefix=""): props = mockup.getPropertyCollectionImpl() nexTprops = props.getChildCollection("_nexxT") threadName = nexTprops.getProperty("thread") - if not threadName in self._threads: + if self.singleThreaded: + threadName = "main" + if threadName not in self._threads: # create threads as needed self._threads[threadName] = NexTThread(threadName) self._threads[threadName].addMockup(filtername, mockup) diff --git a/nexxT/core/AppConsole.py b/nexxT/core/AppConsole.py index e932448..bda0043 100644 --- a/nexxT/core/AppConsole.py +++ b/nexxT/core/AppConsole.py @@ -22,6 +22,8 @@ from nexxT.core.ConfigFiles import ConfigFileLoader from nexxT.core.Configuration import Configuration from nexxT.core.Application import Application +from nexxT.core.ActiveApplication import ActiveApplication +from nexxT.core.PluginManager import PythonLibrary # this import is needed for initializing the nexxT qt resources import nexxT.core.qrc_resources # pylint: disable=unused-import from nexxT.interface import Services, FilterState @@ -65,7 +67,7 @@ def setupGuiServices(config): Services.addService("Configuration", MVCConfigurationGUI(config)) Services.addService("Profiling", Profiling()) -def startNexT(cfgfile, active, execScripts, execCode, withGui): +def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=False, disableUnloadHeuristic=False): """ Starts next with the given config file and activates the given application. :param cfgfile: path to config file @@ -87,6 +89,10 @@ def startNexT(cfgfile, active, execScripts, execCode, withGui): app.setApplicationName("nexxT") setupConsoleServices(config) + ActiveApplication.singleThreaded = singleThreaded + PythonLibrary.disableUnloadHeuristic = disableUnloadHeuristic + + if cfgfile is not None: ConfigFileLoader.load(config, cfgfile) if withGui: @@ -164,18 +170,22 @@ def main(withGui): """) parser.add_argument("cfg", nargs='?', help=".json configuration file of the project to be loaded.") parser.add_argument("-a", "--active", default=None, type=str, - help="active application; default: first application in config file") + help="active application; default: first application in config file.") parser.add_argument("-l", "--logfile", default=None, type=str, help="log file location (.db extension will use sqlite).") parser.add_argument("-v", "--verbosity", default="INFO", choices=["INTERNAL", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "CRITICAL"], help="sets the log verbosity") - parser.add_argument("-q", "--quiet", action="store_true", default=False, help="disble logging to stderr") + parser.add_argument("-q", "--quiet", action="store_true", default=False, help="disble logging to stderr.") parser.add_argument("-e", "--execpython", action="append", default=[], help="execute arbitrary python code given in a string before actually starting the " "application.") parser.add_argument("-s", "--execscript", action="append", default=[], help="execute arbitrary python code given in a file before actually starting the application.") + parser.add_argument("-t", "--single-threaded", action="store_true", default=False, + help="force using only the main thread") + parser.add_argument("-u", "--disable-unload-heuristic", action="store_true", default=False, + help="disable unload heuristic for python modules.") def str2bool(value): if isinstance(value, bool): return value @@ -204,7 +214,8 @@ def str2bool(value): handler = logging.FileHandler(args.logfile) handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) nexT_logger.addHandler(handler) - startNexT(args.cfg, args.active, args.execscript, args.execpython, withGui=args.gui) + startNexT(args.cfg, args.active, args.execscript, args.execpython, withGui=args.gui, + singleThreaded=args.single_threaded, disableUnloadHeuristic=args.disable_unload_heuristic) def mainConsole(): """ diff --git a/nexxT/core/PluginManager.py b/nexxT/core/PluginManager.py index 8997867..9f55e75 100644 --- a/nexxT/core/PluginManager.py +++ b/nexxT/core/PluginManager.py @@ -63,6 +63,8 @@ class PythonLibrary: LIBTYPE_MODULE = 1 LIBTYPE_ENTRY_POINT = 2 + disableUnloadHeuristic = False + # blacklisted packages are not unloaded when closing an application. BLACKLISTED_PACKAGES = ["h5py", "numpy", "matplotlib", "nexxT.Qt", "PySide6", "nexxT.shiboken", "shiboken2", "shiboken6", "torch", "tf"] @@ -143,6 +145,8 @@ def blacklisted(moduleName): :param moduleName: the name of the module as a key in sys.modules """ + if PythonLibrary.disableUnloadHeuristic: + return True pkg = PythonLibrary.BLACKLISTED_PACKAGES[:] if "NEXXT_BLACKLISTED_PACKAGES" in os.environ: if os.environ["NEXXT_BLACKLISTED_PACKAGES"] in ["*", "__all__"]: diff --git a/nexxT/examples/framework/ImageData.py b/nexxT/examples/framework/ImageData.py index fe708cc..eba9c34 100644 --- a/nexxT/examples/framework/ImageData.py +++ b/nexxT/examples/framework/ImageData.py @@ -77,7 +77,7 @@ def byteArrayToNumpy(qByteArray): # create the target array res = np.frombuffer(mv, dtype=dtype, offset=ct.sizeof(hdr)) # reshape to requested dimenstions - return np.reshape(res, (-1, hdr.lineInc//bpp, numChannels)) + return np.reshape(res, (-1, max(1,hdr.lineInc//bpp), numChannels)) def numpyToByteArray(img): """ diff --git a/nexxT/src/FilterEnvironment.cpp b/nexxT/src/FilterEnvironment.cpp index 3cce3df..e435966 100644 --- a/nexxT/src/FilterEnvironment.cpp +++ b/nexxT/src/FilterEnvironment.cpp @@ -94,6 +94,7 @@ void BaseFilterEnvironment::portDataChanged(const InputPortInterface &port) switch( state() ) { case FilterState::OPENED: + case FilterState::STOPPING: NEXXT_LOG_INFO("DataSample discarded because application has been stopped already."); break; case FilterState::CONSTRUCTED: From a1a6ec0297bdb08cb984a97d5a9f490cdeb8ca30 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Tue, 5 Dec 2023 23:21:46 +0100 Subject: [PATCH 13/17] fix issues introduced by last merges --- nexxT/core/PropertyCollectionImpl.py | 1 - nexxT/core/Thread.py | 2 +- nexxT/tests/core/test_EntryPoints.py | 3 ++- nexxT/tests/integration/test_gui.py | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/nexxT/core/PropertyCollectionImpl.py b/nexxT/core/PropertyCollectionImpl.py index 469eb03..243a8a2 100644 --- a/nexxT/core/PropertyCollectionImpl.py +++ b/nexxT/core/PropertyCollectionImpl.py @@ -371,7 +371,6 @@ class PropertyCollectionProxy(PropertyCollection): """ This class proxies to a PropertyCollection object but uses a different instance of variables """ - propertyChanged = Signal(object, str) def __init__(self, proxiedPropColl, variables): PropertyCollection.__init__(self) diff --git a/nexxT/core/Thread.py b/nexxT/core/Thread.py index b9744b1..ba5cdd3 100644 --- a/nexxT/core/Thread.py +++ b/nexxT/core/Thread.py @@ -168,7 +168,7 @@ def performOperation(self, operation, barrier): # wait that all threads are in their event loop. inProcessEvents = self._qthread.property("processEventsRunning") if inProcessEvents: - logging.getLogger(__name__).warning( + logging.getLogger(__name__).debug( "operation %s happening during receiveAsync's processEvents. This shouldn't be happening.", operation) barrier.wait() diff --git a/nexxT/tests/core/test_EntryPoints.py b/nexxT/tests/core/test_EntryPoints.py index 67af48b..a02d5a9 100644 --- a/nexxT/tests/core/test_EntryPoints.py +++ b/nexxT/tests/core/test_EntryPoints.py @@ -15,7 +15,8 @@ cfilters = set(["examples.videoplayback.AviReader", "examples.framework.CameraGrabber", "tests.nexxT.CSimpleSource", - "tests.nexxT.CTestExceptionFilter"]) + "tests.nexxT.CTestExceptionFilter", + "tests.nexxT.CPropertyReceiver"]) blacklist = set([]) diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index 55b3226..e766066 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -1108,7 +1108,8 @@ def _prop_changed(self, variant): # create a node "TheFilter" the_filter = self.addNodeToGraphEditor(gev, QPoint(20,20), CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, - CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_CPROPERTY_RECEIVER) + CM_FILTER_LIBRARY_TESTS_NEXXT, + CM_FILTER_LIBRARY_CPROPERTY_RECEIVER) self.setThreadOfNode(gev, the_filter, "non_main") self.qtbot.wait(self.delay) logger.info("Filter: %s", repr(the_filter)) From 0adaa7b89409a71cc367dcb32ae91b207674e712 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Thu, 4 Jan 2024 14:29:59 +0100 Subject: [PATCH 14/17] fix test_prop_changed* test cases --- nexxT/tests/integration/test_gui.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index e766066..094a98c 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -342,8 +342,12 @@ def setFilterProperty(self, conf, subConfig, filterName, propName, propVal, expe self.qtbot.mouseClick(conf.treeView.viewport(), Qt.LeftButton, pos=region.boundingRect().center(), delay=self.delay) self.qtbot.keyClick(conf.treeView.viewport(), Qt.Key_F2, delay=self.delay) self.aw() + # there are some QT warnings when directly specifying the entertext widget manually, so we try to do without... + self.qtbot.wait(self.delay*3) mw = Services.getService("MainWindow") - self.enterText(propVal, mw.findChild(QWidget, "PropertyDelegateEditor")) + #widgets = [w for w in mw.findChildren(QWidget, "PropertyDelegateEditor") if w.isVisible()] + #assert len(widgets) == 1 + self.enterText(propVal) #, widgets[0]) self.qtbot.wait(self.delay) if expectedVal is None: expectedVal = propVal From 871369089735da1e976114fd3f48f58eb8e2d1b6 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:08:07 +0100 Subject: [PATCH 15/17] add additional command line argument -np (--no-profiling) for disabling the profiling support avoid warnings in console mode about non-existing profiling service minor fix for property editors --- nexxT/core/AppConsole.py | 21 ++++++++++++++++----- nexxT/services/SrvProfiling.py | 23 +++++++++++++++++++++++ nexxT/services/gui/Configuration.py | 2 +- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/nexxT/core/AppConsole.py b/nexxT/core/AppConsole.py index fc3fa62..d055c7e 100644 --- a/nexxT/core/AppConsole.py +++ b/nexxT/core/AppConsole.py @@ -32,6 +32,7 @@ from nexxT.services.SrvConfiguration import MVCConfigurationBase from nexxT.services.SrvPlaybackControl import PlaybackControlConsole from nexxT.services.SrvRecordingControl import MVCRecordingControlBase +from nexxT.services.SrvProfiling import ProfilingServiceDummy from nexxT.services.gui.GuiLogger import GuiLogger from nexxT.services.gui.MainWindow import MainWindow from nexxT.services.gui.Configuration import MVCConfigurationGUI @@ -51,8 +52,9 @@ def setupConsoleServices(config): Services.addService("PlaybackControl", PlaybackControlConsole(config)) Services.addService("RecordingControl", MVCRecordingControlBase(config)) Services.addService("Configuration", MVCConfigurationBase(config)) + Services.addService("Profiling", ProfilingServiceDummy()) -def setupGuiServices(config): +def setupGuiServices(config, disable_profiling=False): """ Adds services available in console mode. :param config: a nexxT.core.Configuration instance @@ -64,9 +66,13 @@ def setupGuiServices(config): Services.addService("PlaybackControl", MVCPlaybackControlGUI(config)) Services.addService("RecordingControl", MVCRecordingControlGUI(config)) Services.addService("Configuration", MVCConfigurationGUI(config)) - Services.addService("Profiling", Profiling()) + if not disable_profiling: + Services.addService("Profiling", Profiling()) + else: + Services.addService("Profiling", ProfilingServiceDummy()) -def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=False, disableUnloadHeuristic=False): +def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=False, disableUnloadHeuristic=False, + disable_profiling=False): """ Starts next with the given config file and activates the given application. :param cfgfile: path to config file @@ -76,12 +82,13 @@ def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=Fa logger.debug("Starting nexxT...") config = Configuration() QLocale.setDefault(QLocale.c()) + if withGui: app = QApplication() if QApplication.instance() is None else QApplication.instance() app.setWindowIcon(QIcon(":icons/nexxT.svg")) app.setOrganizationName("nexxT") app.setApplicationName("nexxT") - setupGuiServices(config) + setupGuiServices(config, disable_profiling=disable_profiling) else: app = QCoreApplication() if QCoreApplication.instance() is None else QCoreApplication.instance() app.setOrganizationName("nexxT") @@ -185,6 +192,9 @@ def main(withGui): help="force using only the main thread") parser.add_argument("-u", "--disable-unload-heuristic", action="store_true", default=False, help="disable unload heuristic for python modules.") + parser.add_argument("-np", "--no-profiling", action="store_true", + help="disable profiling support (only relevant for GUI).") + def str2bool(value): if isinstance(value, bool): return value @@ -214,7 +224,8 @@ def str2bool(value): handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) nexT_logger.addHandler(handler) startNexT(args.cfg, args.active, args.execscript, args.execpython, withGui=args.gui, - singleThreaded=args.single_threaded, disableUnloadHeuristic=args.disable_unload_heuristic) + singleThreaded=args.single_threaded, disableUnloadHeuristic=args.disable_unload_heuristic, + disable_profiling=args.no_profiling) def mainConsole(): """ diff --git a/nexxT/services/SrvProfiling.py b/nexxT/services/SrvProfiling.py index 6783964..99aeb79 100644 --- a/nexxT/services/SrvProfiling.py +++ b/nexxT/services/SrvProfiling.py @@ -167,6 +167,29 @@ def cancel(self): self._portProfiling = {} self._portStack = [] +class ProfilingServiceDummy(QObject): + """ + This class can be used as a replacement for the ProfilingService which provides the same interface. + """ + def __init__(self): + super().__init__() + + @Slot() + def registerThread(self): + pass + + @Slot() + def deregisterThread(self): + pass + + @Slot(str) + def beforePortDataChanged(self, portname): + pass + + @Slot(str) + def afterPortDataChanged(self, portname): + pass + class ProfilingService(QObject): """ This class provides a profiling service for the nexxT framework. diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index f634327..dc26810 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -115,7 +115,7 @@ def __init__(self, configuration): self.treeView.customContextMenuRequested.connect(self._execTreeViewContextMenu) # expand applications by default self.treeView.setExpanded(self.model.index(1, 0), True) - self.delegate = PropertyDelegate(self.model, ITEM_ROLE, ConfigurationModel.PropertyContent, self) + self.delegate = PropertyDelegate(self.model, ITEM_ROLE, ConfigurationModel.PropertyContent, self.treeView) self.treeView.setItemDelegate(self.delegate) self.restoreState() From 82d701863c20309f1416f7607d07d412f3f49125 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Thu, 21 Mar 2024 16:32:40 +0100 Subject: [PATCH 16/17] fix pylint findings --- nexxT/core/AppConsole.py | 10 +++++----- nexxT/core/BaseGraph.py | 2 +- nexxT/services/SrvProfiling.py | 22 ++++++++++++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/nexxT/core/AppConsole.py b/nexxT/core/AppConsole.py index d055c7e..156b484 100644 --- a/nexxT/core/AppConsole.py +++ b/nexxT/core/AppConsole.py @@ -54,7 +54,7 @@ def setupConsoleServices(config): Services.addService("Configuration", MVCConfigurationBase(config)) Services.addService("Profiling", ProfilingServiceDummy()) -def setupGuiServices(config, disable_profiling=False): +def setupGuiServices(config, disableProfiling=False): """ Adds services available in console mode. :param config: a nexxT.core.Configuration instance @@ -66,13 +66,13 @@ def setupGuiServices(config, disable_profiling=False): Services.addService("PlaybackControl", MVCPlaybackControlGUI(config)) Services.addService("RecordingControl", MVCRecordingControlGUI(config)) Services.addService("Configuration", MVCConfigurationGUI(config)) - if not disable_profiling: + if not disableProfiling: Services.addService("Profiling", Profiling()) else: Services.addService("Profiling", ProfilingServiceDummy()) def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=False, disableUnloadHeuristic=False, - disable_profiling=False): + disableProfiling=False): """ Starts next with the given config file and activates the given application. :param cfgfile: path to config file @@ -88,7 +88,7 @@ def startNexT(cfgfile, active, execScripts, execCode, withGui, singleThreaded=Fa app.setWindowIcon(QIcon(":icons/nexxT.svg")) app.setOrganizationName("nexxT") app.setApplicationName("nexxT") - setupGuiServices(config, disable_profiling=disable_profiling) + setupGuiServices(config, disableProfiling=disableProfiling) else: app = QCoreApplication() if QCoreApplication.instance() is None else QCoreApplication.instance() app.setOrganizationName("nexxT") @@ -225,7 +225,7 @@ def str2bool(value): nexT_logger.addHandler(handler) startNexT(args.cfg, args.active, args.execscript, args.execpython, withGui=args.gui, singleThreaded=args.single_threaded, disableUnloadHeuristic=args.disable_unload_heuristic, - disable_profiling=args.no_profiling) + disableProfiling=args.no_profiling) def mainConsole(): """ diff --git a/nexxT/core/BaseGraph.py b/nexxT/core/BaseGraph.py index 22ad995..7d8d15d 100644 --- a/nexxT/core/BaseGraph.py +++ b/nexxT/core/BaseGraph.py @@ -410,4 +410,4 @@ def allOutputPorts(self, node): """ if not node in self._nodes: raise NodeNotFoundError(node) - return self._nodes[node]["outports"] \ No newline at end of file + return self._nodes[node]["outports"] diff --git a/nexxT/services/SrvProfiling.py b/nexxT/services/SrvProfiling.py index 99aeb79..91b50eb 100644 --- a/nexxT/services/SrvProfiling.py +++ b/nexxT/services/SrvProfiling.py @@ -171,24 +171,34 @@ class ProfilingServiceDummy(QObject): """ This class can be used as a replacement for the ProfilingService which provides the same interface. """ - def __init__(self): - super().__init__() @Slot() def registerThread(self): - pass + """ + dummy implementation + """ @Slot() def deregisterThread(self): - pass + """ + dummy implementation + """ @Slot(str) def beforePortDataChanged(self, portname): - pass + """ + dummy implementation + + :param portname: name of the port + """ @Slot(str) def afterPortDataChanged(self, portname): - pass + """ + dummy implementation + + :param portname: name of the port + """ class ProfilingService(QObject): """ From ce142f2872403ab6b438a9675707ae11dc729e7a Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Thu, 21 Mar 2024 16:55:52 +0100 Subject: [PATCH 17/17] fix a few glitches for playback of recordings with t_start=0 when other timestamps are large. --- nexxT/filters/GenericReader.py | 14 +++++++++++--- nexxT/services/gui/PlaybackControl.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/nexxT/filters/GenericReader.py b/nexxT/filters/GenericReader.py index e900e5e..0cf97e8 100644 --- a/nexxT/filters/GenericReader.py +++ b/nexxT/filters/GenericReader.py @@ -382,14 +382,21 @@ def onSuggestDynamicPorts(self): def _timeSpan(self): tmin = math.inf + tminNonZero = math.inf tmax = -math.inf for p in self._portToIdx: - t = self._file.getRcvTimestamp(p, 0) - tmin = min(t, tmin) + for i in range(self._file.getNumberOfSamples(p)): + t = self._file.getRcvTimestamp(p, i) + tmin = min(t, tmin) + if t != 0: + tminNonZero = min(tminNonZero, t) + break t = self._file.getRcvTimestamp(p, self._file.getNumberOfSamples(p)-1) tmax = max(t, tmax) if tmin > tmax: raise RuntimeError("It seems that the input file doesn't have any usable samples.") + if tmin == 0 and tminNonZero > 60*24*self._file.getTimestampResolution(): + tmin = tminNonZero return (tmin*(1000000000//self._file.getTimestampResolution()), tmax*(1000000000//self._file.getTimestampResolution())) @@ -431,7 +438,8 @@ def _transmitNextSample(self): while time.perf_counter_ns() - nowTime < deltaT_ns: pass else: - self._timer.start(deltaT_ns//1000000) + if deltaT_ns < 10e9: + self._timer.start(deltaT_ns//1000000) break else: self.pausePlayback() diff --git a/nexxT/services/gui/PlaybackControl.py b/nexxT/services/gui/PlaybackControl.py index d359a15..0f2f98e 100644 --- a/nexxT/services/gui/PlaybackControl.py +++ b/nexxT/services/gui/PlaybackControl.py @@ -280,7 +280,7 @@ def _currentTimestampChanged(self, currentTime): if self.beginTime is None: self.currentLabel.setText("") else: - sliderVal = (currentTime - self.beginTime) // 1000000 # nanoseconds to milliseconds + sliderVal = max(0, currentTime - self.beginTime) // 1000000 # nanoseconds to milliseconds self.preventSeek = True self.positionSlider.setValue(sliderVal) self.preventSeek = False