From 452ca360549243b75b604947116c28382b0230d9 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Wed, 31 Mar 2021 11:01:13 +0100 Subject: [PATCH 01/17] Improve undo/redo Still not 100% though --- cmdx.py | 227 +++++++++++++++++++++++-------------------------------- tests.py | 2 +- 2 files changed, 96 insertions(+), 133 deletions(-) diff --git a/cmdx.py b/cmdx.py index cdced94..659c71c 100644 --- a/cmdx.py +++ b/cmdx.py @@ -407,7 +407,7 @@ def __call__(cls, mobject, exists=True, modifier=None): else: sup = Node - self = super(Singleton, sup).__call__(mobject, exists, modifier) + self = super(Singleton, sup).__call__(mobject, exists) self._hashCode = hsh self._hexStr = hx cls._instances[hx] = self @@ -514,7 +514,7 @@ def __getitem__(self, key): except RuntimeError: raise ExistError("%s.%s" % (self.path(), key)) - return Plug(self, plug, unit=unit, key=key, modifier=self._modifier) + return Plug(self, plug, unit=unit, key=key) def __setitem__(self, key, value): """Support item assignment of new attributes or values @@ -583,17 +583,6 @@ def __setitem__(self, key, value): plug = self.findPlug(key) plug = Plug(self, plug, unit=unit) - if not getattr(self._modifier, "isDone", True): - - # Only a few attribute types are supported by a modifier - if _python_to_mod(value, plug, self._modifier._modifier): - return - else: - log.warning( - "Could not write %s via modifier, writing directly.." - % plug - ) - # Else, write it immediately plug.write(value) @@ -627,14 +616,12 @@ def __delitem__(self, key): self.deleteAttr(key) @withTiming() - def __init__(self, mobject, exists=True, modifier=None): + def __init__(self, mobject, exists=True): """Initialise Node Private members: mobject (om.MObject): Wrap this MObject fn (om.MFnDependencyNode): The corresponding function set - modifier (om.MDagModifier, optional): Operations are - deferred to this modifier. destroyed (bool): Has this node been destroyed by Maya? state (dict): Optional state for performance @@ -642,7 +629,6 @@ def __init__(self, mobject, exists=True, modifier=None): self._mobject = mobject self._fn = self._Fn(mobject) - self._modifier = modifier self._destroyed = False self._removed = False self._hashCode = None @@ -1260,9 +1246,6 @@ def output(self, connections=connection), None) def rename(self, name): - if not getattr(self._modifier, "isDone", True): - return self._modifier.rename(self, name) - mod = om.MDGModifier() mod.renameNode(self._mobject, name) mod.doIt() @@ -1354,8 +1337,7 @@ def __getitem__(self, key): mplug = mplugs[keys.index(key)] return Plug(self, mplug, unit=None, - key=key, - modifier=self._modifier) + key=key) class DagNode(Node): @@ -2177,8 +2159,8 @@ def members(self, type=None): class AnimCurve(Node): if __maya_version__ >= 2016: - def __init__(self, mobj, exists=True, modifier=None): - super(AnimCurve, self).__init__(mobj, exists, modifier) + def __init__(self, mobj, exists=True): + super(AnimCurve, self).__init__(mobj, exists) self._fna = oma.MFnAnimCurve(mobj) def key(self, time, value, interpolation=Linear): @@ -2596,7 +2578,7 @@ def __hash__(self): """Support storing in set() and as key in dict()""" return hash(self.path()) - def __init__(self, node, mplug, unit=None, key=None, modifier=None): + def __init__(self, node, mplug, unit=None, key=None): """A Maya plug Arguments: @@ -2613,7 +2595,6 @@ def __init__(self, node, mplug, unit=None, key=None, modifier=None): self._unit = unit self._cached = None self._key = key - self._modifier = modifier def plug(self): """Return the MPlug of this cmdx.Plug""" @@ -3333,9 +3314,6 @@ def write(self, value): if isinstance(value, dict) and __maya_version__ > 2015: return self.animate(value) - if not getattr(self._modifier, "isDone", True): - return self._modifier.setAttr(self, value) - try: _python_to_plug(value, self) self._cached = value @@ -3348,9 +3326,6 @@ def write(self, value): raise def connect(self, other, force=True): - if not getattr(self._modifier, "isDone", True): - return self._modifier.connect(self, other, force) - mod = om.MDGModifier() if force: @@ -3393,15 +3368,9 @@ def disconnect(self, other=None, source=True, destination=True): other = getattr(other, "_mplug", None) - if not getattr(self._modifier, "isDone", True): - mod = self._modifier - mod.disconnect(self._mplug, other, source, destination) - # Don't do it, leave that to the parent context - - else: - mod = DGModifier() - mod.disconnect(self._mplug, other, source, destination) - mod.doIt() + mod = DGModifier() + mod.disconnect(self._mplug, other, source, destination) + mod.doIt() def connections(self, type=None, @@ -4606,17 +4575,22 @@ def meters(cm): def clear(): """Clear all memory used by cmdx, including undo""" + Singleton._instances.clear() + if ENABLE_UNDO: - cmds.flushUndo() # Traces left in here can trick Maya into thinking # nodes still exists that cannot be unloaded. - self.shared.undo = None - self.shared.redo = None + self.shared.undoId = None + self.shared.redoId = None self.shared.undos = {} self.shared.redos = {} - Singleton._instances.clear() + cmds.flushUndo() + + # Also ensure Python does its job + import gc + gc.collect() def _encode1(path): @@ -4733,46 +4707,37 @@ def __enter__(self): ... >>> - # Use of modified once exited is not allowed - >>> node = mod.createNode("transform") - >>> mod.doIt() - Traceback (most recent call last): - ... - RuntimeError: Cannot re-use modifier which was once a context - """ + # Given that we perform lots of operations in a single context, + # let's establish our own chunk for it. We'll use the unique + # memory address of this particular instance as a name, + # such that we can identify it amongst the possible-nested + # modifiers once it exits. + cmds.undoInfo(chunkName="%x" % id(self), openChunk=True) + self.isContext = True + return self def __exit__(self, exc_type, exc_value, tb): + cmds.undoInfo(chunkName="%x" % id(self), closeChunk=True) if exc_type: # Let our internal calls to `assert` prevent the # modifier from proceeding, given it's half-baked return - # Support calling `doIt` during a context, - # without polluting the undo queue. - if self.isContext and self._opts["undoable"]: - commit(self._modifier.undoIt, self._modifier.doIt) - - self.doIt() - - # Prevent continued use of the modifier, - # after exiting the context manager. - self.isExited = True + self.redoIt() def __init__(self, undoable=True, interesting=True, debug=True, - atomic=True, + atomic=False, template=None): super(_BaseModifier, self).__init__() - self.isDone = False self.isContext = False - self.isExited = False self._modifier = self.Type() self._history = list() @@ -4813,12 +4778,6 @@ def niceNameAttr(self, plug, value=True): >>> assert node["translateX"].niceName == 'mainAxis' >>> assert node["rotateY"].niceName == 'badRotate' - # Must be undoable - >>> from maya import cmds - >>> cmds.undo() - >>> assert node["translateX"].niceName == 'Translate X' - >>> assert node["rotateY"].niceName == 'Rotate Y' - # Also works with dynamic attributes >>> with DagModifier() as mod: ... node = mod.createNode("transform") @@ -4831,10 +4790,6 @@ def niceNameAttr(self, plug, value=True): ... >>> assert node["myDynamic"].niceName == "Your Dynamic" - # Aaaaand one for the road - >>> cmds.undo() - >>> assert node["myDynamic"].niceName == "My Dynamic" - """ if isinstance(plug, om.MPlug): @@ -4878,6 +4833,7 @@ def lockAttr(self, plug, value=True): ... >>> assert node["myDynamic"].locked >>> cmds.undo() + >>> cmds.undo() # One more for cmds.setAttr >>> assert not node["myDynamic"].locked """ @@ -4907,14 +4863,6 @@ def keyableAttr(self, plug, value=True): >>> node["translateX"].keyable False - # Must be undoable - >>> from maya import cmds - >>> cmds.undo() - >>> node["rotatePivotX"].keyable - False - >>> node["translateX"].keyable - True - # Also works with dynamic attributes >>> with DagModifier() as mod: ... node = mod.createNode("transform") @@ -4928,10 +4876,6 @@ def keyableAttr(self, plug, value=True): >>> node["myDynamic"].keyable True - >>> cmds.undo() - >>> node["myDynamic"].keyable - False - """ if isinstance(plug, om.MPlug): @@ -4950,18 +4894,9 @@ def setNiceName(self, plug, value): return self.niceNameAttr(plug, value) def doIt(self): - if self.isExited: - raise RuntimeError( - "Cannot re-use modifier which was once a context" - ) - - if (not self.isContext) and self._opts["undoable"]: - commit(self._modifier.undoIt, self._modifier.doIt) - try: self._modifier.doIt() - # Do these last, they manage undo on their own with _undo_chunk("lockAttrs"): self._doLockAttrs() self._doKeyableAttrs() @@ -4982,15 +4917,22 @@ def doIt(self): self._history[:] = [] self._attributesBeingAdded[:] = [] - self.isDone = True def undoIt(self): + self._undoLockAttrs() + self._undoKeyableAttrs() + self._undoNiceNames() self._modifier.undoIt() - def _doLockAttrs(self): - if self._opts["undoable"]: - commit(self._undoLockAttrs, self._redoLockAttrs) + def redoIt(self): + self.doIt() + + # Append to undo *after* attempting to do, in case + # do actually fails in which case there's nothing to undo. + if self.isContext and self._opts["undoable"]: + commit(self.undoIt, self.redoIt) + def _doLockAttrs(self): self._redoLockAttrs() def _redoLockAttrs(self): @@ -5015,9 +4957,6 @@ def _undoLockAttrs(self): self._lockAttrs += [(plug, newValue)] def _doKeyableAttrs(self): - if self._opts["undoable"]: - commit(self._undoKeyableAttrs, self._redoKeyableAttrs) - self._redoKeyableAttrs() def _redoKeyableAttrs(self): @@ -5050,9 +4989,6 @@ def _doNiceNames(self): """ - if self._opts["undoable"]: - commit(self._undoNiceNames, self._redoNiceNames) - self._redoNiceNames() def _redoNiceNames(self): @@ -5092,7 +5028,7 @@ def createNode(self, type, name=None): ) self._modifier.renameNode(mobj, name) - node = Node(mobj, exists=False, modifier=self) + node = Node(mobj, exists=False) if not self._opts["interesting"]: plug = node["isHistoricallyInteresting"] @@ -5142,6 +5078,11 @@ def deleteNode(self, node): self._modifier.deleteNode(node._mobject) + if False: + # Deletion via modifiers seem awefully unstable + # Should we just use this instead? :S + om.MGlobal.deleteNode(node._mobject) + @record_history def renameNode(self, node, name): return self._modifier.renameNode(node._mobject, name) @@ -5159,7 +5100,6 @@ def addAttr(self, node, attr): >>> node["newAttr"].read() 5.0 >>> cmds.undo() - >>> cmds.undo() >>> node.hasAttr("newAttr") False @@ -5251,6 +5191,12 @@ def setAttr(self, plug, value): _python_to_mod(value, plug, self._modifier) + def trySetAttr(self, plug, value): + try: + self.setAttr(plug, value) + except Exception: + pass + def resetAttr(self, plug): self.setAttr(plug, plug.default) @@ -5273,7 +5219,6 @@ def connect(self, src, dst, force=True): >>> tm["tx"].connection() |myTransform >>> cmds.undo() - >>> cmds.undo() # Unsure why it needs two undos :S >>> tm["tx"].connection() is None True @@ -5351,7 +5296,6 @@ def connectAttr(self, srcPlug, dstNode, dstAttr): # Also works with undo >>> cmds.undo() - >>> cmds.undo() >>> newNode.hasAttr("newAttr") False >>> cmds.redo() @@ -5366,7 +5310,6 @@ def connectAttr(self, srcPlug, dstNode, dstAttr): >>> newNode["newAttr"].connection() |otherNode - >>> cmds.undo() >>> cmds.undo() >>> newNode["newAttr"].connection() |newNode @@ -5587,6 +5530,7 @@ def disconnect(self, a, b=None, source=True, destination=True): rename_node = renameNode add_attr = addAttr set_attr = setAttr + try_set_attr = trySetAttr delete_attr = deleteAttr reset_attr = resetAttr lock_attr = lockAttr @@ -5640,9 +5584,11 @@ class DagModifier(_BaseModifier): >>> mod.connect(parent["tz"], shape["tz"]) >>> mod.setAttr(parent["sx"], 2.0) >>> parent["tx"] >> shape["ty"] + >>> round(shape["ty"], 1) + 0.0 >>> parent["tx"] = 5.1 >>> round(shape["ty"], 1) # Not yet created nor connected - 0.0 + 5.1 >>> mod.doIt() >>> round(shape["ty"], 1) 5.1 @@ -5705,7 +5651,7 @@ def createNode(self, type, name=None, parent=None): ) self._modifier.renameNode(mobj, name) - return DagNode(mobj, exists=False, modifier=self) + return DagNode(mobj, exists=False) @record_history def parent(self, node, parent=None): @@ -6211,10 +6157,6 @@ def curve(parent, points, degree=1, form=kOpen): "parent must be of type cmdx.DagNode" ) - assert parent._modifier is None or parent._modifier.isDone, ( - "curve() currently doesn't work with a modifier" - ) - # Superimpose end knots # startpoints = [points[0]] * (degree - 1) # endpoints = [points[-1]] * (degree - 1) @@ -6927,8 +6869,8 @@ class Distance4(Compound): sys.modules[unique_shared] = types.ModuleType(unique_shared) shared = sys.modules[unique_shared] -shared.undo = None -shared.redo = None +shared.undoId = None +shared.redoId = None shared.undos = {} shared.redos = {} @@ -6954,17 +6896,17 @@ def commit(undo, redo=lambda: None): # from a single thread, which should already be the case # given that Maya's API is not threadsafe. try: - assert shared.redo is None - assert shared.undo is None + assert shared.redoId is None + assert shared.undoId is None except AssertionError: log.debug("%s has a problem with undo" % __name__) # Temporarily store the functions at shared-level, # they are later picked up by the command once called. - shared.undo = "%x" % id(undo) - shared.redo = "%x" % id(redo) - shared.undos[shared.undo] = undo - shared.redos[shared.redo] = redo + shared.undoId = "%x" % id(undo) + shared.redoId = "%x" % id(redo) + shared.undos[shared.undoId] = undo + shared.redos[shared.redoId] = redo # Let Maya know that something is undoable getattr(cmds, unique_command)() @@ -7042,19 +6984,40 @@ def maya_useNewAPI(): class _apiUndo(om.MPxCommand): + # For debugging, should always be 0 unless there's something to undo + _aliveCount = 0 + + def __init__(self, *args, **kwargs): + super(_apiUndo, self).__init__(*args, **kwargs) + _apiUndo._aliveCount += 1 + + def __del__(self): + _apiUndo._aliveCount -= 1 + self.undoId = None + self.redoId = None + def doIt(self, args): - self.undo = shared.undo - self.redo = shared.redo - # Facilitate the above precautionary measure - shared.undo = None - shared.redo = None + # Store the last undo/redo commands in this + # instance of the _apiUndo command. + self.undoId = shared.undoId + self.redoId = shared.redoId + + # With that stored, let's avoid storing it elsewhere + shared.undoId = None + shared.redoId = None def undoIt(self): - shared.undos[self.undo]() + try: + shared.undos.pop(self.undoId)() + except KeyError: + pass def redoIt(self): - shared.redos[self.redo]() + try: + shared.redos.pop(self.redoId)() + except KeyError: + pass def isUndoable(self): # Without this, the above undoIt and redoIt will not be called diff --git a/tests.py b/tests.py index 27adef2..d15035e 100644 --- a/tests.py +++ b/tests.py @@ -440,7 +440,7 @@ def test_modifier_first_error(): def test_modifier_atomicity(): """Modifier rolls back changes on failure""" - mod = cmdx.DagModifier() + mod = cmdx.DagModifier(atomic=True) node = mod.createNode("transform", name="UniqueName") mod.connect(node["translateX"], node["translateY"]) mod.setAttr(node["translateY"], 5.0) From f49860583a5b681135cfe23f99837fd7131753f3 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sun, 4 Apr 2021 17:22:30 +0100 Subject: [PATCH 02/17] Augment SAFE_MODE --- cmdx.py | 497 +++++++++++++++++++------------------------------------- 1 file changed, 165 insertions(+), 332 deletions(-) diff --git a/cmdx.py b/cmdx.py index 659c71c..7571576 100644 --- a/cmdx.py +++ b/cmdx.py @@ -38,13 +38,10 @@ # as during an auto rigging build or export process. ROGUE_MODE = not SAFE_MODE and bool(os.getenv("CMDX_ROGUE_MODE")) -# Increase performance by not bothering to free up unused memory -MEMORY_HOG_MODE = not SAFE_MODE and bool(os.getenv("CMDX_MEMORY_HOG_MODE")) - ENABLE_PEP8 = True -# Support undo/redo -ENABLE_UNDO = not SAFE_MODE +# Support undo/redo (mandatory) +ENABLE_UNDO = True # Required ENABLE_PLUG_REUSE = True @@ -196,15 +193,36 @@ def _undo_chunk(name): cmds.undoInfo(chunkName=name, closeChunk=True) +if SAFE_MODE: + def _isalive(mobj): + """Make as sure as humanly-possible that this mobject is safe""" + if not mobj.isNull(): + return False + + handle = om.MObjectHandle(mobj) + if not handle.isValid(): + return False + + if not handle.isAlive(): + return False + + return True + +else: + def _isalive(mobj): + return True + + def protected(func): """Prevent fatal crashes from illegal access to deleted nodes""" - if ROGUE_MODE: + if ROGUE_MODE or not SAFE_MODE: return func @wraps(func) def func_wrapper(*args, **kwargs): - if args[0]._destroyed: - raise ExistError("Cannot perform operation on deleted node") + node = args[0] + assert isinstance(node, Node) + assert _isalive(node._mobject) return func(*args, **kwargs) return func_wrapper @@ -384,12 +402,11 @@ def __call__(cls, mobject, exists=True, modifier=None): if exists and handle.isValid(): try: node = cls._instances[hx] - assert not node._destroyed + assert om.MObjectHandle(node._mobject).isAlive() except (KeyError, AssertionError): pass else: Stats.NodeReuseCount += 1 - node._removed = False return node # It hasn't been instantiated before, let's do that. @@ -590,28 +607,6 @@ def __hash__(self): """Support storing in set() and as key in dict()""" return hash(self.path()) - def _onDestroyed(self, mobject): - self._destroyed = True - - om.MMessage.removeCallbacks(self._state["callbacks"]) - - for callback in self.onDestroyed: - try: - callback(self) - except Exception: - traceback.print_exc() - - _data.pop(self.hex, None) - - def _onRemoved(self, mobject, modifier, _=None): - self._removed = True - - for callback in self.onRemoved: - try: - callback() - except Exception: - traceback.print_exc() - def __delitem__(self, key): self.deleteAttr(key) @@ -627,39 +622,27 @@ def __init__(self, mobject, exists=True): """ + if SAFE_MODE: + assert _isalive(mobject) + + else: + # In "production mode", we'll re-use the same function set + self._fn = self._Fn(mobject) + self._mobject = mobject - self._fn = self._Fn(mobject) - self._destroyed = False - self._removed = False self._hashCode = None self._state = { - "plugs": dict(), "values": dict(), "callbacks": list() } - # Callbacks - self.onDestroyed = list() - self.onRemoved = list() - Stats.NodeInitCount += 1 - self._state["callbacks"] += [ - # Monitor node deletion, to prevent accidental - # use of MObject past its lifetime which may - # result in a fatal crash. - om.MNodeMessage.addNodeDestroyedCallback( - mobject, - self._onDestroyed, # func - None # clientData - ) if not ROGUE_MODE else 0, - - om.MNodeMessage.addNodeAboutToDeleteCallback( - mobject, - self._onRemoved, - None - ), - ] + if SAFE_MODE: + @property + def _fn(self): + assert _isalive(self._mobject) + return self._Fn(self._mobject) def plugin(self): """Return the user-defined class of the plug-in behind this node""" @@ -674,8 +657,8 @@ def object(self): return self._mobject def isAlive(self): - """The node exists somewhere in memory""" - return not self._destroyed + """The node exists in the scene""" + return om.MObjectHandle(self._mobject).isAlive() @property def data(self): @@ -692,7 +675,7 @@ def data(self): @property def destroyed(self): - return self._destroyed + return not om.MObjectHandle(self._mobject).isValid() @property def exists(self): @@ -715,11 +698,11 @@ def exists(self): """ - return not self._removed + return not self.removed and not self.destroyed @property def removed(self): - return self._removed + return om.MObjectHandle(self._mobject).isAlive() @property def hashCode(self): @@ -839,52 +822,37 @@ def findPlug(self, name, cached=False, safe=True): Arguments: name (str): Name of plug to find - cached (bool, optional): Return cached plug, or + cached (bool, optional): (DEPRECATED) Return cached plug, or throw an exception. Default to False, which means it will run Maya's findPlug() and cache the result. - safe (bool, optional): Always find the plug through + safe (bool, optional): (DEPRECATED) Always find the plug through Maya's API, defaults to True. This will not perform any caching and is intended for use during debugging to spot whether caching is causing trouble. Example: >>> node = createNode("transform") - >>> node.findPlug("translateX", cached=True) - Traceback (most recent call last): - ... - KeyError: "'translateX' not cached" >>> plug1 = node.findPlug("translateX") >>> isinstance(plug1, om.MPlug) True - >>> plug1 is node.findPlug("translateX", safe=False) - True - >>> plug1 is node.findPlug("translateX", cached=True) + >>> plug1 is node.findPlug("translateX") True """ - if cached or not safe: - try: - existing = self._state["plugs"][name] - Stats.PlugReuseCount += 1 - return existing - - except KeyError: - # The user explicitly asked for a cached attribute, - # if this is not the case we must tell them about it - if cached: - raise KeyError("'%s' not cached" % name) - assert isinstance(name, string_types), "%s was not string" % name + assert _isalive(self._mobject) try: - plug = self._fn.findPlug(name, True) + # We always want a non-networked plug. It's safer and as-fast. + # https://forums.autodesk.com/t5/maya-programming/maya-api-what-is-a-networked-plug-and-do-i-want-it-or-not/td-p/7182472 + want_networked_plug = False + plug = self._fn.findPlug(name, want_networked_plug) + except RuntimeError: raise ExistError("%s.%s" % (self.path(), name)) - self._state["plugs"][name] = plug - return plug def update(self, attrs): @@ -929,7 +897,6 @@ def clear(self): """ - self._state["plugs"].clear() self._state["values"].clear() @protected @@ -1379,9 +1346,16 @@ def __repr__(self): def __init__(self, mobject, *args, **kwargs): super(DagNode, self).__init__(mobject, *args, **kwargs) - # Convert self._tfn to om.MFnTransform(self.dagPath()) - # if you want to use its functions which require sWorld - self._tfn = om.MFnTransform(mobject) + if not SAFE_MODE: + # Convert self._tfn to om.MFnTransform(self.dagPath()) + # if you want to use its functions which require sWorld + self._tfn = om.MFnTransform(mobject) + + if SAFE_MODE: + @property + def _tfn(self): + assert _isalive(self._mobject) + return om.MFnTransform(self._mobject) @protected def path(self): @@ -3409,11 +3383,15 @@ def connections(self, if type is None or node.isA(type): if plugs: - # for some reason mplug.connectedTo returns networked plugs - # sometimes, we have to convert them before using them + if SAFE_MODE: + assert not plug.isNull + + # For some reason mplug.connectedTo returns networked plugs + # sometimes, we have to convert them before using them. # https://forums.autodesk.com/t5/maya-programming/maya-api-what-is-a-networked-plug-and-do-i-want-it-or-not/td-p/7182472 if plug.isNetworked: plug = node.findPlug(plug.partialName()) + yield Plug(node, plug, unit) else: yield node @@ -4654,6 +4632,12 @@ def decode(node): def record_history(func): + if SAFE_MODE: + # Getting of `node.path()` involves use of a function + # set. But if an MObject is no valid, we'd better not + # try and query it. + return func + @wraps(func) def decorator(self, *args, **kwargs): _kwargs = kwargs.copy() @@ -4750,163 +4734,17 @@ def __init__(self, "template": template, } - # Extras - self._lockAttrs = [] - self._keyableAttrs = [] - self._niceNames = [] - - # Undo - self._doneLockAttrs = [] - self._doneKeyableAttrs = [] - self._doneNiceNames = [] self._attributesBeingAdded = [] - @record_history - def niceNameAttr(self, plug, value=True): - """Set a new nice name for a plug - - The modifier doesn't natively support this, so we - stow away the request and call it at the tail end - of call to `doIt`. - - Examples: - >>> with DagModifier() as mod: - ... node = mod.createNode("transform") - ... mod.niceNameAttr(node["translateX"], "mainAxis") - ... mod.niceNameAttr(node["rotateY"], "badRotate") - ... - >>> assert node["translateX"].niceName == 'mainAxis' - >>> assert node["rotateY"].niceName == 'badRotate' - - # Also works with dynamic attributes - >>> with DagModifier() as mod: - ... node = mod.createNode("transform") - ... _ = mod.addAttr(node, Double("myDynamic")) - ... - >>> assert node["myDynamic"].niceName == "My Dynamic" - - >>> with DagModifier() as mod: - ... mod.niceNameAttr(node["myDynamic"], "Your Dynamic") - ... - >>> assert node["myDynamic"].niceName == "Your Dynamic" - - """ - - if isinstance(plug, om.MPlug): - plug = Plug(Node(plug.node()), plug) - - assert isinstance(plug, Plug), "%s was not a plug" % plug - self._niceNames.append((plug, value)) - - @record_history - def lockAttr(self, plug, value=True): - """Lock a plug - - The modifier doesn't natively support this, so we - stow away the request and call it at the tail end - of call to `doIt`. - - Examples: - >>> with DagModifier() as mod: - ... node = mod.createNode("transform") - ... mod.lockAttr(node["translateX"]) - ... mod.lockAttr(node["rotateY"]) - ... - >>> assert node["translateX"].locked - >>> assert node["rotateY"].locked - - # Must be undoable - >>> from maya import cmds - >>> cmds.undo() - >>> assert not node["translateX"].locked - >>> assert not node["rotateY"].locked - - # Also works with dynamic attributes - >>> with DagModifier() as mod: - ... node = mod.createNode("transform") - ... _ = mod.addAttr(node, Double("myDynamic")) - ... - >>> assert not node["myDynamic"].locked - - >>> with DagModifier() as mod: - ... mod.lockAttr(node["myDynamic"]) - ... - >>> assert node["myDynamic"].locked - >>> cmds.undo() - >>> cmds.undo() # One more for cmds.setAttr - >>> assert not node["myDynamic"].locked - - """ - - if isinstance(plug, om.MPlug): - plug = Plug(Node(plug.node()), plug) - - assert isinstance(plug, Plug), "%s was not a plug" % plug - self._lockAttrs.append((plug, value)) - - @record_history - def keyableAttr(self, plug, value=True): - """Make a plug keyable - - The modifier doesn't natively support this, so we - stow away the request and call it at the tail end - of call to `doIt`. - - Examples: - >>> with DagModifier() as mod: - ... node = mod.createNode("transform") - ... mod.keyableAttr(node["rotatePivotX"]) - ... mod.keyableAttr(node["translateX"], False) - ... - >>> node["rotatePivotX"].keyable - True - >>> node["translateX"].keyable - False - - # Also works with dynamic attributes - >>> with DagModifier() as mod: - ... node = mod.createNode("transform") - ... _ = mod.addAttr(node, Double("myDynamic")) - ... - >>> node["myDynamic"].keyable - False - >>> with DagModifier() as mod: - ... mod.keyableAttr(node["myDynamic"]) - ... - >>> node["myDynamic"].keyable - True - - """ - - if isinstance(plug, om.MPlug): - plug = Plug(Node(plug.node()), plug) - - assert isinstance(plug, Plug), "%s was not a plug" % plug - self._keyableAttrs.append((plug, value)) - - def setKeyable(self, plug, value=True): - return self.keyableAttr(plug, value) - - def setLocked(self, plug, value=True): - return self.lockAttr(plug, value) - - def setNiceName(self, plug, value): - return self.niceNameAttr(plug, value) - def doIt(self): try: self._modifier.doIt() - with _undo_chunk("lockAttrs"): - self._doLockAttrs() - self._doKeyableAttrs() - self._doNiceNames() - except RuntimeError: # Rollback changes if self._opts["atomic"]: - self.undoIt() + self._modifier.undoIt() traceback.print_exc() raise ModifierError(self._history) @@ -4919,9 +4757,6 @@ def doIt(self): self._attributesBeingAdded[:] = [] def undoIt(self): - self._undoLockAttrs() - self._undoKeyableAttrs() - self._undoNiceNames() self._modifier.undoIt() def redoIt(self): @@ -4932,86 +4767,6 @@ def redoIt(self): if self.isContext and self._opts["undoable"]: commit(self.undoIt, self.redoIt) - def _doLockAttrs(self): - self._redoLockAttrs() - - def _redoLockAttrs(self): - while self._lockAttrs: - plug, value = self._lockAttrs.pop(0) - elements = plug if plug.isArray or plug.isCompound else [plug] - - for el in elements: - - # Undo is handled by the plug itself, by calling on cmds - if not el._mplug.isDynamic: - self._doneLockAttrs += [(el, el.locked, value)] - - el.locked = value - - def _undoLockAttrs(self): - while self._doneLockAttrs: - plug, oldValue, newValue = self._doneLockAttrs.pop(0) - plug.locked = oldValue - - # For redo - self._lockAttrs += [(plug, newValue)] - - def _doKeyableAttrs(self): - self._redoKeyableAttrs() - - def _redoKeyableAttrs(self): - while self._keyableAttrs: - plug, value = self._keyableAttrs.pop(0) - elements = plug if plug.isArray or plug.isCompound else [plug] - - for el in elements: - if not el._mplug.isDynamic: - self._doneKeyableAttrs += [(el, el.keyable, value)] - el.keyable = value - - def _undoKeyableAttrs(self): - while self._doneKeyableAttrs: - plug, oldValue, newValue = self._doneKeyableAttrs.pop(0) - plug.keyable = oldValue - - # For redo - self._keyableAttrs += [(plug, newValue)] - - def _doNiceNames(self): - """Apply all of the new nice names - - Examples: - >>> with _BaseModifier() as mod: - ... node = mod.createNode("reverse") - ... mod.setNiceName(node["inputX"], "Test") - ... - >>> assert node["inputX"].niceName == "Test" - - """ - - self._redoNiceNames() - - def _redoNiceNames(self): - while self._niceNames: - plug, value = self._niceNames.pop(0) - elements = plug if plug.isArray or plug.isCompound else [plug] - - for el in elements: - # No API access? - oldValue = cmds.attributeName(el.path(), nice=True) - self._doneNiceNames += [(el, oldValue, value)] - - fn = om.MFnAttribute(el._mplug.attribute()) - fn.setNiceNameOverride(value) - - def _undoNiceNames(self): - while self._doneNiceNames: - plug, oldValue, newValue = self._doneNiceNames.pop(0) - fn = om.MFnAttribute(plug._mplug.attribute()) - fn.setNiceNameOverride(oldValue) - - self._niceNames += [(plug, newValue)] - @record_history def createNode(self, type, name=None): try: @@ -5028,6 +4783,11 @@ def createNode(self, type, name=None): ) self._modifier.renameNode(mobj, name) + if SAFE_MODE: + # Create every node immediately, to allow for + # calls to MObjectHandle.isAlive() + self._modifier.doIt() + node = Node(mobj, exists=False) if not self._opts["interesting"]: @@ -5076,15 +4836,16 @@ def deleteNode(self, node): """ + assert _isalive(node._mobject) self._modifier.deleteNode(node._mobject) - if False: - # Deletion via modifiers seem awefully unstable - # Should we just use this instead? :S - om.MGlobal.deleteNode(node._mobject) + # This appears to happen regardless of calling doIt yourself, + # and the documentation recommends you do it. + self._modifier.doIt() @record_history def renameNode(self, node, name): + assert _isalive(node._mobject) return self._modifier.renameNode(node._mobject, name) @record_history @@ -5127,6 +4888,8 @@ def addAttr(self, node, attr): """ assert isinstance(node, Node), "%s was not a cmdx.Node" + assert _isalive(node._mobject) + mobj = attr if isinstance(attr, _AbstractAttribute): @@ -5163,22 +4926,38 @@ def addAttr(self, node, attr): # you try and undo. Bad, Maya, bad! self._attributesBeingAdded += [(node, mobj)] + if SAFE_MODE: + self._modifier.doIt() + return mobj @record_history def deleteAttr(self, plug): + assert isinstance(plug, Plug), "%s was not a cmdx.Plug" % plug + assert not plug._mplug.isNull + node = plug.node() + assert _isalive(node._mobject) + + # Erase cached values, they're no longer valid node.clear() - return self._modifier.removeAttribute( + result = self._modifier.removeAttribute( node._mobject, plug._mplug.attribute() ) + if SAFE_MODE: + self._modifier.doIt() + + return result + @record_history def setAttr(self, plug, value): if isinstance(plug, om.MPlug): + assert not plug.isNull plug = Plug(plug.node(), plug) + assert not plug._mplug.isNull assert plug.editable, "%s was locked or connected" % plug.path() # Support passing a cmdx.Plug as value @@ -5191,6 +4970,9 @@ def setAttr(self, plug, value): _python_to_mod(value, plug, self._modifier) + if SAFE_MODE: + self._modifier.doIt() + def trySetAttr(self, plug, value): try: self.setAttr(plug, value) @@ -5243,6 +5025,9 @@ def connect(self, src, dst, force=True): assert isinstance(src, om.MPlug), "%s must be of type MPlug" % src assert isinstance(dst, om.MPlug), "%s must be of type MPlug" % dst + assert not src.isNull + assert not dst.isNull + if dst.isLocked: # Modifier can't perform this connect, but wouldn't # tell you about it until you `doIt()`. Bad. @@ -5323,10 +5108,12 @@ def connectAttr(self, srcPlug, dstNode, dstAttr): if isinstance(srcPlug, Plug): srcPlug = srcPlug._mplug + assert not srcPlug.isNull srcNode = srcPlug.node() srcAttr = srcPlug.attribute() if isinstance(dstNode, Node): + assert _isalive(dstNode._mobject) dstNode = dstNode.object() return self.connectAttrs(srcNode, srcAttr, dstNode, dstAttr) @@ -5347,18 +5134,51 @@ def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): >>> newNode["newAttr"].read() 1.0 + # Support for passing attribute by name + >>> with DagModifier() as mod: + ... newNode = mod.createNode("transform") + ... mod.addAttr(newNode, Double("newAttr")) + ... mod.connectAttr(newNode["visibility"], newNode, "newAttr") + ... + >>> newNode["newAttr"].read() + 1.0 + + # Support for passing both attributes by name + >>> with DagModifier() as mod: + ... newNode = mod.createNode("transform") + ... mod.addAttr(newNode, Double("newAttr")) + ... mod.connectAttrs(newNode, "visibility", newNode, "newAttr") + ... + >>> newNode["newAttr"].read() + 1.0 + """ + if SAFE_MODE: + # Ensure any node or attribute going into this method + # has actually already been created. + self._modifier.doIt() + if isinstance(srcNode, Node): srcNode = srcNode.object() if isinstance(dstNode, Node): dstNode = dstNode.object() + if isinstance(srcAttr, string_types): + # Support passing of attributes as string + srcAttr = om.MFnDependencyNode(srcNode).attribute(srcAttr) + + if isinstance(dstAttr, string_types): + dstAttr = om.MFnDependencyNode(dstNode).attribute(dstAttr) + if isinstance(srcAttr, Plug): + # Support passing of attributes as cmdx.Plug + assert not srcAttr._mplug.isNull srcAttr = srcAttr.attribute() if isinstance(dstAttr, Plug): + assert not dstAttr._mplug.isNull dstAttr = dstAttr.attribute() assert isinstance(srcNode, om.MObject) @@ -5366,9 +5186,17 @@ def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): assert isinstance(dstNode, om.MObject) assert isinstance(dstAttr, om.MObject) + assert not srcAttr.isNull() + assert not dstAttr.isNull() + assert _isalive(srcNode) + assert _isalive(dstNode) + self._modifier.connect(srcNode, srcAttr, dstNode, dstAttr) + if SAFE_MODE: + self._modifier.doIt() + def tryConnect(self, src, dst): """Connect and ignore failure @@ -5494,6 +5322,9 @@ def disconnect(self, a, b=None, source=True, destination=True): if isinstance(b, Plug): b = b._mplug + assert not a.isNull + assert not b.isNull + count = 0 incoming = (True, False) outgoing = (False, True) @@ -5505,6 +5336,7 @@ def disconnect(self, a, b=None, source=True, destination=True): if b is not None and other != b: continue + assert not other.isNull self._modifier.disconnect(other, a) count += 1 @@ -5513,6 +5345,7 @@ def disconnect(self, a, b=None, source=True, destination=True): if b is not None and other != b: continue + assert not other.isNull self._modifier.disconnect(a, other) count += 1 @@ -5533,15 +5366,9 @@ def disconnect(self, a, b=None, source=True, destination=True): try_set_attr = trySetAttr delete_attr = deleteAttr reset_attr = resetAttr - lock_attr = lockAttr - keyable_attr = keyableAttr - nice_name_attr = niceNameAttr try_connect = tryConnect connect_attr = connectAttr connect_attrs = connectAttrs - set_keyable = setKeyable - set_locked = setLocked - set_nice_name = setNiceName class DGModifier(_BaseModifier): @@ -5651,6 +5478,9 @@ def createNode(self, type, name=None, parent=None): ) self._modifier.renameNode(mobj, name) + if SAFE_MODE: + self._modifier.doIt() + return DagNode(mobj, exists=False) @record_history @@ -5658,6 +5488,9 @@ def parent(self, node, parent=None): parent = parent._mobject if parent is not None else om.MObject.kNullObj self._modifier.reparentNode(node._mobject, parent) + if SAFE_MODE: + self._modifier.doIt() + if ENABLE_PEP8: create_node = createNode From ae467fcd6d9a45a261f8f6ba9684e476efe232af Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sun, 4 Apr 2021 18:10:39 +0100 Subject: [PATCH 03/17] Sleep well, 2015 and 2016. It was nice knowing you. But seriously though, these can't install pip anymore since PyPI is demanding the world stops using Python 2.7 immediately. No matter the consequences of maintaining legacy software. And they know best. --- .github/workflows/main.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c8a7b31..168aeb5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,10 +24,6 @@ jobs: matrix: include: - - maya: "2015sp6" - pip: "2.7/get-pip.py" - - maya: "2016sp1" - pip: "2.7/get-pip.py" - maya: "2017" pip: "2.7/get-pip.py" - maya: "2018" From cc6b3334c593d38f0ca5a7e85505c8d49234408e Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sun, 4 Apr 2021 20:05:03 +0100 Subject: [PATCH 04/17] Safer, stronger --- cmdx.py | 398 +++++++++++++++++++++++++++++++++++++++++++------------ tests.py | 32 +++-- 2 files changed, 333 insertions(+), 97 deletions(-) diff --git a/cmdx.py b/cmdx.py index 7571576..99db6b0 100644 --- a/cmdx.py +++ b/cmdx.py @@ -193,36 +193,40 @@ def _undo_chunk(name): cmds.undoInfo(chunkName=name, closeChunk=True) -if SAFE_MODE: - def _isalive(mobj): - """Make as sure as humanly-possible that this mobject is safe""" - if not mobj.isNull(): - return False +def _isalive(mobj): + """Make as sure as humanly-possible that this mobject is safe""" - handle = om.MObjectHandle(mobj) - if not handle.isValid(): - return False + # Rare case of an empty MObject being passed, e.g. MObject() + if mobj.isNull(): + return False - if not handle.isAlive(): - return False + handle = om.MObjectHandle(mobj) - return True + # The node has been destroyed, e.g. new scene or flushed undo + if not handle.isValid(): + return False -else: - def _isalive(mobj): - return True + # The node is present in the scene, but has been removed. Could + # potentially be undone and given new life. We don't care. + if not handle.isAlive(): + return False + + return True def protected(func): """Prevent fatal crashes from illegal access to deleted nodes""" - if ROGUE_MODE or not SAFE_MODE: + if ROGUE_MODE: return func @wraps(func) def func_wrapper(*args, **kwargs): node = args[0] - assert isinstance(node, Node) - assert _isalive(node._mobject) + assert isinstance(node, Node), "arg[0] should have been a cmdx.Node" + + if node.destroyed or not _isalive(node._mobject): + raise ExistError("Cannot perform operation on deleted node") + return func(*args, **kwargs) return func_wrapper @@ -402,9 +406,15 @@ def __call__(cls, mobject, exists=True, modifier=None): if exists and handle.isValid(): try: node = cls._instances[hx] - assert om.MObjectHandle(node._mobject).isAlive() - except (KeyError, AssertionError): + assert not node._destroyed + + except KeyError: pass + + except AssertionError: + # He's dead Jim + cls._instances.pop(hx) + else: Stats.NodeReuseCount += 1 return node @@ -458,9 +468,18 @@ class Node(object): def __eq__(self, other): """MObject supports this operator explicitly""" + + # On scene-open, an old MObject can reference a new node, + # most typically the `top` camera node. Therefore, it isn't + # enough to only compare MObject to MObject + try: # Better to ask forgivness than permission - return self._mobject == other._mobject + return ( + _isalive(self._mobject) and + _isalive(other._mobject) and + self._mobject == other._mobject + ) except AttributeError: return str(self) == str(other) @@ -622,27 +641,45 @@ def __init__(self, mobject, exists=True): """ - if SAFE_MODE: - assert _isalive(mobject) - - else: - # In "production mode", we'll re-use the same function set - self._fn = self._Fn(mobject) - self._mobject = mobject + self._destroyed = False self._hashCode = None self._state = { "values": dict(), "callbacks": list() } + # There is no humanly possible way of knowing when + # an MObject is destroyed, other than to listen for + # it via a callback. Please correct me if I'm wrong, + # callbacks are death. + self._state["callbacks"] += [ + # Monitor node deletion, to prevent accidental + # use of MObject past its lifetime which may + # result in a fatal crash. + om.MNodeMessage.addNodeDestroyedCallback( + mobject, + self._onDestroyed, # func + None # clientData + ) + ] + Stats.NodeInitCount += 1 - if SAFE_MODE: - @property - def _fn(self): + def __del__(self): + for callback in self._state["callbacks"]: + om.MMessage.removeCallback(callback) + self._state["callbacks"].clear() + + def _onDestroyed(self, mobject, _=None): + self._destroyed = True + + @property + def _fn(self): + if SAFE_MODE: assert _isalive(self._mobject) - return self._Fn(self._mobject) + + return self._Fn(self._mobject) def plugin(self): """Return the user-defined class of the plug-in behind this node""" @@ -673,10 +710,6 @@ def data(self): return _data[self.hex] - @property - def destroyed(self): - return not om.MObjectHandle(self._mobject).isValid() - @property def exists(self): """The node exists in both memory *and* scene @@ -688,21 +721,21 @@ def exists(self): >>> cmds.delete(str(node)) >>> node.exists False - >>> node.destroyed - False >>> _ = cmds.file(new=True, force=True) >>> node.exists False - >>> node.destroyed - True """ - return not self.removed and not self.destroyed + return _isalive(self._mobject) @property def removed(self): - return om.MObjectHandle(self._mobject).isAlive() + return not _isalive(self._mobject) + + @property + def destroyed(self): + return self._destroyed @property def hashCode(self): @@ -836,13 +869,17 @@ def findPlug(self, name, cached=False, safe=True): >>> plug1 = node.findPlug("translateX") >>> isinstance(plug1, om.MPlug) True - >>> plug1 is node.findPlug("translateX") + >>> plug1 == node.findPlug("translateX") True """ assert isinstance(name, string_types), "%s was not string" % name - assert _isalive(self._mobject) + + # `findPlug` has a tendency of bringing Maya down with it. + # Let's not give it the satisfaction. + if not _isalive(self._mobject): + raise ExistError try: # We always want a non-networked plug. It's safer and as-fast. @@ -1343,19 +1380,14 @@ def __str__(self): def __repr__(self): return self.path() - def __init__(self, mobject, *args, **kwargs): - super(DagNode, self).__init__(mobject, *args, **kwargs) - - if not SAFE_MODE: - # Convert self._tfn to om.MFnTransform(self.dagPath()) - # if you want to use its functions which require sWorld - self._tfn = om.MFnTransform(mobject) - - if SAFE_MODE: - @property - def _tfn(self): + @property + def _tfn(self): + if SAFE_MODE: assert _isalive(self._mobject) - return om.MFnTransform(self._mobject) + + # Convert self._tfn to om.MFnTransform(self.dagPath()) + # if you want to use its functions which require sWorld + return om.MFnTransform(self._mobject) @protected def path(self): @@ -4452,6 +4484,10 @@ def encode(path): # type: (str) -> Node raise ExistError("'%s' does not exist" % path) mobj = selectionList.getDependNode(0) + + # Deleted nodes can still get picked up, unless + # they are also destroyed. But we don't care for + # removed-but-not-destroyed nodes return Node(mobj) @@ -4705,14 +4741,16 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, tb): - cmds.undoInfo(chunkName="%x" % id(self), closeChunk=True) - if exc_type: # Let our internal calls to `assert` prevent the # modifier from proceeding, given it's half-baked return - self.redoIt() + try: + self.redoIt() + + finally: + cmds.undoInfo(chunkName="%x" % id(self), closeChunk=True) def __init__(self, undoable=True, @@ -4736,6 +4774,157 @@ def __init__(self, self._attributesBeingAdded = [] + # Extras + self._lockAttrs = [] + self._keyableAttrs = [] + self._niceNames = [] + + # Undo + self._doneLockAttrs = [] + self._doneKeyableAttrs = [] + self._doneNiceNames = [] + + self._attributesBeingAdded = [] + + @record_history + def setNiceName(self, plug, value=True): + """Set a new nice name for a plug + + The modifier doesn't natively support this, so we + stow away the request and call it at the tail end + of call to `doIt`. + + Examples: + # Only works with dynamic attributes + >>> with DagModifier() as mod: + ... node = mod.createNode("transform") + ... _ = mod.addAttr(node, Double("myDynamic")) + ... + >>> assert node["myDynamic"].niceName == "My Dynamic" + + >>> with DagModifier() as mod: + ... mod.setNiceName(node["myDynamic"], "Your Dynamic") + ... + >>> assert node["myDynamic"].niceName == "Your Dynamic" + + """ + + if isinstance(plug, om.MPlug): + plug = Plug(Node(plug.node()), plug) + + assert isinstance(plug, Plug), "%s was not a plug" % plug + assert plug._mplug.isDynamic, "%s was not a dynamic attribute" % plug + self._niceNames.append((plug, value)) + + @record_history + def setLocked(self, plug, value=True): + """Lock a plug + + The modifier doesn't natively support this, so we + stow away the request and call it at the tail end + of call to `doIt`. + + Examples: + >>> node = createNode("transform") + >>> with DagModifier() as mod: + ... mod.setLocked(node["translateX"]) + ... mod.setLocked(node["rotateY"]) + ... + >>> assert node["translateX"].locked + >>> assert node["rotateY"].locked + + # Must be undoable + >>> from maya import cmds + >>> cmds.undo() + >>> assert not node["translateX"].locked + >>> assert not node["rotateY"].locked + + # Also works with dynamic attributes + >>> with DagModifier() as mod: + ... node = mod.createNode("transform") + ... _ = mod.addAttr(node, Double("myDynamic")) + ... + >>> assert not node["myDynamic"].locked + + >>> with DagModifier() as mod: + ... mod.setLocked(node["myDynamic"]) + ... + >>> assert node["myDynamic"].locked + >>> cmds.undo() + >>> assert not node["myDynamic"].locked + + """ + + if isinstance(plug, om.MPlug): + plug = Plug(Node(plug.node()), plug) + + assert isinstance(plug, Plug), "%s was not a plug" % plug + self._lockAttrs.append((plug, value)) + + @record_history + def setKeyable(self, plug, value=True): + """Make a plug keyable + + The modifier doesn't natively support this, so we + stow away the request and call it at the tail end + of call to `doIt`. + + Examples: + >>> with DagModifier() as mod: + ... node = mod.createNode("transform") + ... mod.setKeyable(node["rotatePivotX"]) + ... mod.setKeyable(node["translateX"], False) + ... + >>> node["rotatePivotX"].keyable + True + >>> node["translateX"].keyable + False + + # Also works with dynamic attributes + >>> with DagModifier() as mod: + ... node = mod.createNode("transform") + ... _ = mod.addAttr(node, Double("myDynamic")) + ... + >>> node["myDynamic"].keyable + False + >>> with DagModifier() as mod: + ... mod.setKeyable(node["myDynamic"]) + ... + >>> node["myDynamic"].keyable + True + + """ + + if isinstance(plug, om.MPlug): + plug = Plug(Node(plug.node()), plug) + + assert isinstance(plug, Plug), "%s was not a plug" % plug + self._keyableAttrs.append((plug, value)) + + def _doLockAttrs(self): + while self._lockAttrs: + plug, value = self._lockAttrs.pop(0) + elements = plug if plug.isArray or plug.isCompound else [plug] + + for el in elements: + cmds.setAttr(el.path(), lock=value) + + def _doKeyableAttrs(self): + while self._keyableAttrs: + plug, value = self._keyableAttrs.pop(0) + elements = plug if plug.isArray or plug.isCompound else [plug] + + for el in elements: + cmds.setAttr(el.path(), keyable=value) + + def _doNiceNames(self): + while self._niceNames: + plug, value = self._niceNames.pop(0) + elements = plug if plug.isArray or plug.isCompound else [plug] + + for el in elements: + cmds.addAttr(el.path(), edit=True, niceName=value) + def doIt(self): try: self._modifier.doIt() @@ -4765,7 +4954,16 @@ def redoIt(self): # Append to undo *after* attempting to do, in case # do actually fails in which case there's nothing to undo. if self.isContext and self._opts["undoable"]: - commit(self.undoIt, self.redoIt) + # We'll commit doIt rather than redoIt, since + # the below special-commands are called via cmds + # and manage undo/redo on their own, without our help. + commit(self.undoIt, self.doIt) + + # These all involve calling on cmds, + # which manages undo on its own. + self._doLockAttrs() + self._doKeyableAttrs() + self._doNiceNames() @record_history def createNode(self, type, name=None): @@ -4783,10 +4981,9 @@ def createNode(self, type, name=None): ) self._modifier.renameNode(mobj, name) - if SAFE_MODE: - # Create every node immediately, to allow for - # calls to MObjectHandle.isAlive() - self._modifier.doIt() + # Create every node immediately, to allow for + # calls to MObjectHandle.isAlive() + self._modifier.doIt() node = Node(mobj, exists=False) @@ -4836,16 +5033,24 @@ def deleteNode(self, node): """ - assert _isalive(node._mobject) - self._modifier.deleteNode(node._mobject) + # This is one picky s-o-b, let's not give it the + # satisfaction of ever erroring out on us. Performance + # is of less importance here, as deletion is not time-cricital + mobj = node._mobject + if not _isalive(mobj): + raise ExistError + + self._modifier.deleteNode(mobj) # This appears to happen regardless of calling doIt yourself, - # and the documentation recommends you do it. + # and the documentation recommends you do it always. Let's do it. self._modifier.doIt() @record_history def renameNode(self, node, name): - assert _isalive(node._mobject) + if SAFE_MODE: + assert _isalive(node._mobject) + return self._modifier.renameNode(node._mobject, name) @record_history @@ -4888,7 +5093,9 @@ def addAttr(self, node, attr): """ assert isinstance(node, Node), "%s was not a cmdx.Node" - assert _isalive(node._mobject) + + if SAFE_MODE: + assert _isalive(node._mobject) mobj = attr @@ -4937,7 +5144,9 @@ def deleteAttr(self, plug): assert not plug._mplug.isNull node = plug.node() - assert _isalive(node._mobject) + + if SAFE_MODE: + assert _isalive(node._mobject) # Erase cached values, they're no longer valid node.clear() @@ -5101,9 +5310,12 @@ def connectAttr(self, srcPlug, dstNode, dstAttr): """ - assert isinstance(srcPlug, (Plug, om.MPlug)), "srcPlug not a plug" - assert isinstance(dstNode, (Node, om.MObject)), "dstNode not a node" - assert isinstance(dstAttr, om.MObject), "dstAttr not an MObject" + assert isinstance(srcPlug, (Plug, om.MPlug)), "%s not a plug" % srcPlug + assert isinstance(dstNode, (Node, om.MObject)), "%s not node" % dstNode + assert ( + isinstance(dstAttr, om.MObject) or + isinstance(dstAttr, string_types) + ), "%s not an MObject" % dstAttr if isinstance(srcPlug, Plug): srcPlug = srcPlug._mplug @@ -5113,10 +5325,15 @@ def connectAttr(self, srcPlug, dstNode, dstAttr): srcAttr = srcPlug.attribute() if isinstance(dstNode, Node): + assert _isalive(dstNode._mobject) dstNode = dstNode.object() - return self.connectAttrs(srcNode, srcAttr, dstNode, dstAttr) + if SAFE_MODE: + assert _isalive(srcNode) + assert _isalive(dstNode) + + self.connectAttrs(srcNode, srcAttr, dstNode, dstAttr) def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): """Connect a new attribute to another new attribute @@ -5137,7 +5354,8 @@ def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): # Support for passing attribute by name >>> with DagModifier() as mod: ... newNode = mod.createNode("transform") - ... mod.addAttr(newNode, Double("newAttr")) + ... _ = mod.addAttr(newNode, Double("newAttr")) + ... mod.doIt() ... mod.connectAttr(newNode["visibility"], newNode, "newAttr") ... >>> newNode["newAttr"].read() @@ -5146,7 +5364,8 @@ def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): # Support for passing both attributes by name >>> with DagModifier() as mod: ... newNode = mod.createNode("transform") - ... mod.addAttr(newNode, Double("newAttr")) + ... _ = mod.addAttr(newNode, Double("newAttr")) + ... mod.doIt() ... mod.connectAttrs(newNode, "visibility", newNode, "newAttr") ... >>> newNode["newAttr"].read() @@ -5167,10 +5386,18 @@ def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): if isinstance(srcAttr, string_types): # Support passing of attributes as string - srcAttr = om.MFnDependencyNode(srcNode).attribute(srcAttr) + name = srcAttr + srcAttr = om.MFnDependencyNode(dstNode).attribute(name) + + if srcAttr.isNull(): + raise ExistError("Could not find %s.attribute %s" % name) if isinstance(dstAttr, string_types): - dstAttr = om.MFnDependencyNode(dstNode).attribute(dstAttr) + name = dstAttr + dstAttr = om.MFnDependencyNode(dstNode).attribute(name) + + if dstAttr.isNull(): + raise ExistError("Could not find %s.attribute %s" % name) if isinstance(srcAttr, Plug): # Support passing of attributes as cmdx.Plug @@ -5188,8 +5415,9 @@ def connectAttrs(self, srcNode, srcAttr, dstNode, dstAttr): assert not srcAttr.isNull() assert not dstAttr.isNull() - assert _isalive(srcNode) - assert _isalive(dstNode) + + if SAFE_MODE: + assert _isalive(srcNode) and _isalive(dstNode) self._modifier.connect(srcNode, srcAttr, dstNode, dstAttr) @@ -5243,7 +5471,7 @@ def disconnect(self, a, b=None, source=True, destination=True): """Disconnect `a` from `b` Normally, Maya only performs a disconnect if the - connection is incoming. Bidirectional + connection is incoming. disconnect(A, B) => OK __________ _________ @@ -5322,8 +5550,8 @@ def disconnect(self, a, b=None, source=True, destination=True): if isinstance(b, Plug): b = b._mplug - assert not a.isNull - assert not b.isNull + assert a and not a.isNull + assert b is None or not b.isNull count = 0 incoming = (True, False) @@ -5369,6 +5597,9 @@ def disconnect(self, a, b=None, source=True, destination=True): try_connect = tryConnect connect_attr = connectAttr connect_attrs = connectAttrs + set_keyable = setKeyable + set_locked = setLocked + set_nice_name = setNiceName class DGModifier(_BaseModifier): @@ -5478,8 +5709,7 @@ def createNode(self, type, name=None, parent=None): ) self._modifier.renameNode(mobj, name) - if SAFE_MODE: - self._modifier.doIt() + self._modifier.doIt() return DagNode(mobj, exists=False) diff --git a/tests.py b/tests.py index d15035e..41db722 100644 --- a/tests.py +++ b/tests.py @@ -225,17 +225,18 @@ def test_nodereuse(): assert_is(cmdx.encode("|myNode"), nodeA) assert_is(nodeB.parent(), nodeA) - with tempdir() as tmp: - fname = os.path.join(tmp, "myScene.ma") - cmds.file(rename=fname) - cmds.file(save=True, type="mayaAscii") - cmds.file(fname, open=True, force=True) - - # On scene open, the current scene is closed, triggering - # the nodeDestroyed callback which invalidates the node - # for cmdx. Upon encoding this node anew, cmdx will - # allocate a new instance for it. - assert_is_not(cmdx.encode("|myNode"), nodeA) + # On scene open, the current scene is closed which *should* + # invalidate all MObjects. However. An old MObject can sometimes + # reference a new node, most typically the `top` camera node.# + # It doesn't always happen, and appears random. So we should test + # a few more times, just to make more sure. + for attempt in range(5): + with tempdir() as tmp: + fname = os.path.join(tmp, "myScene.ma") + cmds.file(rename=fname) + cmds.file(save=True, type="mayaAscii") + cmds.file(fname, open=True, force=True) + assert_is_not(cmdx.encode("|myNode"), nodeA) @with_setup(new_scene) @@ -254,8 +255,13 @@ def test_nodereuse_noexist(): # from a non-existing node. assert_raises(cmdx.ExistError, cmdx.encode, "|myNode") - # Any operation on a deleted node raises RuntimeError - assert_raises(RuntimeError, lambda: nodeA.name()) + # Any operation on a deleted node raises ExistError + try: + print(nodeA.name()) + except cmdx.ExistError: + pass + else: + assert False @with_setup(new_scene) From 8a77b11c1aae2d6c4eeb666882bf8875960b091d Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 5 Apr 2021 06:49:15 +0100 Subject: [PATCH 05/17] Raise TypeError on attempting to write unsupported type to modifier --- cmdx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmdx.py b/cmdx.py index 99db6b0..68670dc 100644 --- a/cmdx.py +++ b/cmdx.py @@ -4445,7 +4445,7 @@ def _python_to_mod(value, plug, mod): _python_to_mod(value, plug[index], mod) else: - log.warning( + raise TypeError( "Unsupported plug type for modifier: %s" % type(value) ) return False From 2f129b51f96bf8e88d6cdb154076a7aa519574bc Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 5 Apr 2021 08:39:17 +0100 Subject: [PATCH 06/17] More safety --- cmdx.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cmdx.py b/cmdx.py index 68670dc..3f7250a 100644 --- a/cmdx.py +++ b/cmdx.py @@ -667,8 +667,19 @@ def __init__(self, mobject, exists=True): Stats.NodeInitCount += 1 def __del__(self): + """Clean up callbacks on garbage collection + + These may/should clean up themselves alongside node + destruction, but in case they don't we make extra sure. + + """ + for callback in self._state["callbacks"]: - om.MMessage.removeCallback(callback) + try: + om.MMessage.removeCallback(callback) + except RuntimeError: + pass + self._state["callbacks"].clear() def _onDestroyed(self, mobject, _=None): @@ -694,8 +705,8 @@ def object(self): return self._mobject def isAlive(self): - """The node exists in the scene""" - return om.MObjectHandle(self._mobject).isAlive() + """The node exists somewhere in memory""" + return not self._destroyed @property def data(self): From 8031d628df3a3754f9ef303e26d7005301ae7429 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 5 Apr 2021 17:00:56 +0100 Subject: [PATCH 07/17] Even more safety, and tests for undo/redo --- cmdx.py | 115 ++++++++++++++++++++++++++++++++------------- tests.py | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 33 deletions(-) diff --git a/cmdx.py b/cmdx.py index 3f7250a..f1129c2 100644 --- a/cmdx.py +++ b/cmdx.py @@ -47,8 +47,10 @@ ENABLE_PLUG_REUSE = True if PY3: + long = int string_types = str, else: + long = long string_types = str, basestring, unicode try: @@ -4740,12 +4742,13 @@ def __enter__(self): """ - # Given that we perform lots of operations in a single context, - # let's establish our own chunk for it. We'll use the unique - # memory address of this particular instance as a name, - # such that we can identify it amongst the possible-nested - # modifiers once it exits. - cmds.undoInfo(chunkName="%x" % id(self), openChunk=True) + if self._opts["undoable"]: + # Given that we perform lots of operations in a single context, + # let's establish our own chunk for it. We'll use the unique + # memory address of this particular instance as a name, + # such that we can identify it amongst the possible-nested + # modifiers once it exits. + cmds.undoInfo(chunkName="%x" % id(self), openChunk=True) self.isContext = True @@ -4760,8 +4763,21 @@ def __exit__(self, exc_type, exc_value, tb): try: self.redoIt() + # These all involve calling on cmds, + # which manages undo on its own. + self._doLockAttrs() + self._doKeyableAttrs() + self._doNiceNames() + finally: - cmds.undoInfo(chunkName="%x" % id(self), closeChunk=True) + if self._opts["undoable"]: + + # Make our commit within the current undo chunk, + # to combine the commands from the above special + # attribute edits which happen via maya.cmds + commit(self._modifier.undoIt, self._modifier.doIt) + + cmds.undoInfo(chunkName="%x" % id(self), closeChunk=True) def __init__(self, undoable=True, @@ -4962,20 +4978,6 @@ def undoIt(self): def redoIt(self): self.doIt() - # Append to undo *after* attempting to do, in case - # do actually fails in which case there's nothing to undo. - if self.isContext and self._opts["undoable"]: - # We'll commit doIt rather than redoIt, since - # the below special-commands are called via cmds - # and manage undo/redo on their own, without our help. - commit(self.undoIt, self.doIt) - - # These all involve calling on cmds, - # which manages undo on its own. - self._doLockAttrs() - self._doKeyableAttrs() - self._doNiceNames() - @record_history def createNode(self, type, name=None): try: @@ -5178,7 +5180,8 @@ def setAttr(self, plug, value): plug = Plug(plug.node(), plug) assert not plug._mplug.isNull - assert plug.editable, "%s was locked or connected" % plug.path() + if not plug.editable: + raise LockedError("%s was locked or connected" % plug.path()) # Support passing a cmdx.Plug as value if isinstance(value, Plug): @@ -5193,6 +5196,38 @@ def setAttr(self, plug, value): if SAFE_MODE: self._modifier.doIt() + def smartSetAttr(self, plug, value): + """Convenience method for setAttr + + If the plug being set is driven by another attribute, + attempt to set *its* value instead. + + This is intended for semi-proxy attributes, which take + the place of an attribute elsewhere. When actual proxy + attributes are not possible (as they are garbage). + + """ + + if plug.editable: + # No smarts necessary + return self.set_attr(plug, value) + + if plug.locked: + # No amount of smarts is going to save us from this one + raise LockedError("%s was locked" % plug.path()) + + # Let's try and set the attribute on the connected plug instead + connection = plug.connection(plug=True, + source=True, + destination=False) + + assert connection is not None, ( + "Attribute was not locked, but also not connected? " + "Then what is it? This case isn't handled and is a bug." + ) + + return self.set_attr(connection, value) + def trySetAttr(self, plug, value): try: self.setAttr(plug, value) @@ -5603,6 +5638,7 @@ def disconnect(self, a, b=None, source=True, destination=True): add_attr = addAttr set_attr = setAttr try_set_attr = trySetAttr + smart_set_attr = smartSetAttr delete_attr = deleteAttr reset_attr = resetAttr try_connect = tryConnect @@ -6069,7 +6105,10 @@ def delete(*nodes): # Use DAG modifier rather than DG, because # DG doesn't understand hierarchy. - with DagModifier() as mod: + # Do not make undoable, such that it may + # be used alongside :func:`commit` and within + # plug-ins that manage undo themselves + with DagModifier(undoable=False) as mod: for node in flattened: if isinstance(node, str): node, node = node.rsplit(".", 1) @@ -7067,6 +7106,22 @@ def __init__(self, *args, **kwargs): def __del__(self): _apiUndo._aliveCount -= 1 + + # Relive whatever was held in memory + # This *should* always contain the undo ID + # of the current command instance. If it doesn't, + # the `shared` module must have been either + # edited or deleted outside of cmdx, such as + # if the module was reloaded. + + # However, we can't afford throwing errors here, + # and errors here isn't a big whop anyway since they + # would be deleted and cleaned up on unloading + # of the `cmdx` module along with the `shared` + # instance from sys.module. E.g. on Maya restart. + shared.undos.pop(self.undoId, None) + shared.redos.pop(self.redoId, None) + self.undoId = None self.redoId = None @@ -7082,16 +7137,14 @@ def doIt(self, args): shared.redoId = None def undoIt(self): - try: - shared.undos.pop(self.undoId)() - except KeyError: - pass + # If the undo ID does not exist, it means + # we've erased commands still active in the undo + # queue, which isn't good. E.g. the cmdx module + # was reloaded. + shared.undos[self.undoId]() def redoIt(self): - try: - shared.redos.pop(self.redoId)() - except KeyError: - pass + shared.redos[self.redoId]() def isUndoable(self): # Without this, the above undoIt and redoIt will not be called diff --git a/tests.py b/tests.py index 41db722..8bcb8d7 100644 --- a/tests.py +++ b/tests.py @@ -453,8 +453,8 @@ def test_modifier_atomicity(): assert_raises(cmdx.ModifierError, mod.doIt) - # Node got created, even though - assert "UniqueName" not in cmds.ls() + # Node never got created + assert "|UniqueName" not in cmds.ls() def test_modifier_history(): @@ -474,3 +474,138 @@ def test_modifier_history(): assert_equals(tasks[2], "setAttr") else: assert False, "I should have failed" + + +def test_modifier_undo(): + new_scene() + + with cmdx.DagModifier() as mod: + mod.createNode("transform", name="nodeA") + mod.createNode("transform", name="nodeB") + mod.createNode("transform", name="nodeC") + + assert "|nodeC" in cmdx.ls() + cmds.undo() + assert "|nodeC" not in cmdx.ls() + + +def test_modifier_locked(): + """Modifiers properly undo setLocked""" + + new_scene() + node = cmdx.createNode("transform") + assert not node["translateX"].locked + + with cmdx.DagModifier() as mod: + mod.setLocked(node["translateX"], True) + + assert node["translateX"].locked + cmds.undo() + assert not node["translateX"].locked + cmds.redo() + assert node["translateX"].locked + cmds.undo() + assert not node["translateX"].locked + + +def test_modifier_keyable(): + """Modifiers properly undo setKeyable""" + + new_scene() + node = cmdx.createNode("transform") + assert node["translateX"].keyable + + with cmdx.DagModifier() as mod: + mod.setKeyable(node["translateX"], False) + + assert not node["translateX"].keyable + cmds.undo() + assert node["translateX"].keyable + cmds.redo() + assert not node["translateX"].keyable + cmds.undo() + assert node["translateX"].keyable + + +def test_modifier_nicename(): + """Modifiers properly undo setNiceName""" + + new_scene() + node = cmdx.createNode("transform") + node["myName"] = cmdx.Double() + assert node["myName"].niceName == "My Name" + + with cmdx.DagModifier() as mod: + mod.setNiceName(node["myName"], "Nice Name") + + assert node["myName"].niceName == "Nice Name" + cmds.undo() + assert node["myName"].niceName == "My Name" + cmds.redo() + assert node["myName"].niceName == "Nice Name" + cmds.undo() + assert node["myName"].niceName == "My Name" + + +def test_modifier_plug_cmds_undo(): + """cmds and Modifiers undo in the same chunk""" + + new_scene() + with cmdx.DagModifier() as mod: + mod.createNode("transform", name="cmdxNode") + cmds.createNode("transform", name="cmdsNode") + + assert "|cmdxNode" in cmdx.ls() + assert "|cmdsNode" in cmdx.ls() + + cmds.undo() + + assert "|cmdxNode" not in cmdx.ls() + assert "|cmdsNode" not in cmdx.ls() + + cmds.redo() + + assert "|cmdxNode" in cmdx.ls() + assert "|cmdsNode" in cmdx.ls() + + cmds.undo() + + assert "|cmdxNode" not in cmdx.ls() + assert "|cmdsNode" not in cmdx.ls() + + +def test_commit_undo(): + """commit is as stable as Modifiers""" + + new_scene() + + # Maintain reference to this + test_commit_undo.node = None + + def do(): + test_commit_undo.node = cmdx.createNode("transform", name="nodeA") + + do() + + def undo(): + cmdx.delete(test_commit_undo.node) + + cmdx.commit(undo=undo, redo=do) + + assert "|nodeA" in cmdx.ls() + + cmds.undo() + + assert "|nodeA" not in cmdx.ls() + + cmds.redo() + + assert "|nodeA" in cmdx.ls() + + cmds.undo() + + assert "|nodeA" not in cmdx.ls() + + +def test_modifier_redo(): + pass From c28c240af6b94acb7e5b767537f44253159e5f76 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 5 Apr 2021 17:15:28 +0100 Subject: [PATCH 08/17] Repair CI for old old Python --- .github/workflows/main.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 168aeb5..5600406 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,17 +42,18 @@ jobs: - name: Checkout code uses: actions/checkout@v1 + # We'll lock each version to one that works with both Python 2.7 and 3.7 - name: pip install run: | wget https://bootstrap.pypa.io/pip/${{ matrix.pip }} mayapy get-pip.py --user mayapy -m pip install --user \ - nose \ - nose-exclude \ - coverage \ - flaky \ - sphinx \ - sphinxcontrib-napoleon + nose==1.3.7 \ + nose-exclude==0.5.0 \ + coverage==5.5 \ + flaky==3.7.0 \ + sphinx==1.8.5 \ + sphinxcontrib-napoleon==0.7 # Since 2019, this sucker throws an unnecessary warning if not declared - name: Environment From a6ec4a9dc6e290b6d76e97519283973c5e434399 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 5 Apr 2021 17:17:03 +0100 Subject: [PATCH 09/17] Opt out of analytics for Maya 2022 --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5600406..a709789 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -59,6 +59,7 @@ jobs: - name: Environment run: | export XDG_RUNTIME_DIR=/var/tmp/runtime-root + export MAYA_DISABLE_ADP=1 - name: Unittests run: | From f42f80f0a5f0eba398a89b84667f9d56e0f1c78c Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 5 Apr 2021 17:56:00 +0100 Subject: [PATCH 10/17] Increment version, update README --- README.md | 155 +++++++++++++++++++++++++++++++++++++++--------------- cmdx.py | 2 +- 2 files changed, 113 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 7d407ae..20ea495 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

-

A fast subset of maya.cmds
For Maya 2015-2022

+

A fast subset of maya.cmds
For Maya 2017-2022


@@ -23,6 +23,7 @@ On average, `cmdx` is **140x faster** than [PyMEL](https://github.com/LumaPictur | Date | Version | Event |:---------|:----------|:---------- +| Apr 2020 | 0.6.0 | Stable Undo/Redo, dropped support for Maya 2015-2016 | Mar 2020 | 0.5.1 | Support for Maya 2022 | Mar 2020 | 0.5.0 | Stable release | Aug 2019 | 0.4.0 | Public release @@ -35,8 +36,6 @@ On average, `cmdx` is **140x faster** than [PyMEL](https://github.com/LumaPictur | Maya | Status |:----------|:---- -| 2015 | [![cmdx-test](https://github.com/mottosso/cmdx/actions/workflows/main.yml/badge.svg)](https://github.com/mottosso/cmdx/actions/workflows/main.yml) -| 2016 | [![cmdx-test](https://github.com/mottosso/cmdx/actions/workflows/main.yml/badge.svg)](https://github.com/mottosso/cmdx/actions/workflows/main.yml) | 2017 | [![cmdx-test](https://github.com/mottosso/cmdx/actions/workflows/main.yml/badge.svg)](https://github.com/mottosso/cmdx/actions/workflows/main.yml) | 2018 | [![cmdx-test](https://github.com/mottosso/cmdx/actions/workflows/main.yml/badge.svg)](https://github.com/mottosso/cmdx/actions/workflows/main.yml) | 2019 | [![cmdx-test](https://github.com/mottosso/cmdx/actions/workflows/main.yml/badge.svg)](https://github.com/mottosso/cmdx/actions/workflows/main.yml) @@ -76,7 +75,6 @@ With [so many options](#comparison) for interacting with Maya, when or why shoul - [Node and attribute reuse](#query-reduction) - [Transactions](#transactions) - [Hashable References](#hashable-references) -- [Signals](#signals) - [PEP8 Dual Syntax](#pep8-dual-syntax)
@@ -157,7 +155,7 @@ With [so many options](#comparison) for interacting with Maya, when or why shoul ### System Requirements -`cmdx` runs on Maya 2015 SP3 and above (SP2 does *not* work). +`cmdx` runs on Maya 2017 above. It *may* run on older versions too, but those are not being tested. To bypass the version check, see [`CMDX_IGNORE_VERSION`](#cmdx_ignore_version). @@ -597,14 +595,15 @@ For undo, you've got two options. node = cmdx.createNode("transform") ``` -This operation is undoable, because under the hood it calls `cmdx.DagModifier`. +This operation is not undoable and is intended for use with `cmdx.commit` and/or within a Python plug-in. ```py node["translateX"] = 5 node["tx"] >> node["ty"] +cmdx.delete(node) ``` -These operations however is *not* undoable. +These operations are also not undoable. In order to edit attributes with support for undo, you must use either a modifier or call `commit`. This is how the Maya API normally works, for both Python and C++. @@ -638,7 +637,10 @@ With this level of control, you are able to put Maya in a bad state. ```py a = cmdx.encode("existingNode") -b = cmdx.createNode("transform", name="newNode") + +with cmdx.DagModifier() as mod: + b = mod.createNode("transform", name="newNode") + b["ty"] >> a["tx"] ``` @@ -753,8 +755,6 @@ for member in objset: print(member) ``` -> NOTE: `MFnSet` was first introduced to the Maya Python API 2.0 in Maya 2016 and has been backported to work with `cmdx` in Maya 2015, leveraging the equivalent functionality found in API 1.0. It does however mean that there is a performance impact in Maya <2016 of roughly 0.01 ms/node. -
### Attribute Query and Assignment @@ -1163,6 +1163,8 @@ assert b.child(contains="nurbsCurve") != c **Drawing a line** + + ```python import cmdx @@ -1177,6 +1179,8 @@ This creates a new `nurbsCurve` shape and fills it with points. Append the `degree` argument for a smooth curve. + + ```python import cmdx @@ -1192,13 +1196,15 @@ shape["cached"] = cmdx.NurbsCurveData( Append the `form` argument for closed loop. + + ```python import cmdx parent = cmdx.createNode("transform") shape = cmdx.createNode("nurbsCurve", parent=parent) shape["cached"] = cmdx.NurbsCurveData( - points=((0, 0, 0), (1, 1, 0), (0, 2, 0)), + points=((1, 1, 0), (-1, 1, 0), (-1, -1, 0), (1, -1, 0)), degree=2, form=cmdx.kClosed ) @@ -1554,7 +1560,7 @@ It's not all roses; in order of severity: Modifiers in `cmdx` extend the native modifiers with these extras. 1. **Automatically undoable** Like `cmds` -2. **Transactional** Changes are automatically rolled back on error, making every modifier atomic +2. **Atomic** Changes are automatically rolled back on error, making every modifier atomic 3. **Debuggable** Maya's native modifier throws an error without including what or where it happened. `cmdx` provides detailed diagnostics of what was supposed to happen, what happened, attempts to figure out why and what line number it occurred on. 4. **Name templates** Reduce character count by delegating a "theme" of names across many new nodes. @@ -1572,24 +1578,14 @@ with cmdx.DagModifier() as mod: Now when calling `undo`, the above lines will be undone as you'd expect. -If you prefer, modern syntax still works here. - -```python -with cmdx.DagModifier() as mod: - parent = mod.createNode("transform", name="MyParent") - child = mod.createNode("transform", parent=parent) - parent["translate"] = (1, 2, 3) - parent["rotate"] >> child["rotate"] -``` - -And PEP8. +There is also a completely equivalent PEP8 syntax. ```python with cmdx.DagModifier() as mod: parent = mod.create_node("transform", name="MyParent") child = mod.create_node("transform", parent=parent) - parent["translate"] = (1, 2, 3) - parent["rotate"] >> child["rotate"] + mod.set_attr(parent + ".translate", (1, 2, 3)) + mod.connect(parent + ".rotate", child + ".rotate") ``` Name templates look like this. @@ -1601,37 +1597,110 @@ with cmdx.DagModifier(template="myName_{type}") as mod: assert node.name() == "myName_transform" ``` -This makes it easy to move a block of code into a modifier without changing things around. Perhaps to test performance, or to figure out whether undo support is necessary. +##### Connect To Newly Created Attribute -##### Limitations +Creating a new attribute returns a "promise" of that attribute being created. You can pass that to `connectAttr` to both create and connect attributes in the same modifier. -The modifier is quite limited in what features it provides; in general, it can only *modify* the scenegraph, it cannot query it. +```py +with cmdx.DagModifier() as mod: + node = mod.createNode("transform") + attr = mod.createAttr(node, cmdx.Double("myNewAttr")) + mod.connectAttr(node["translateX"], attr) +``` -1. It cannot read attributes -2. It cannot set complex attribute types, such as meshes or nurbs curves -3. It cannot query a future hierarchy, such as asking for the parent or children of a newly created node +You can even connect *two* previously unexisting attributes at the same time with `connectAttrs`. -Furthermore, there are a few limitations with regards to modern syntax. +```py -1. It cannot connect an existing attribute to one on a newly node, e.g. `existing["tx"] >> new["tx"]` -2. ... +with cmdx.DagModifier() as mod: + node = mod.createNode("transform") + attr1 = mod.createAttr(node, cmdx.Double("attr1")) + attr2 = mod.createAttr(node, cmdx.Double("attr2")) + mod.connectAttrs(node, attr1, node, attr2) +``` -
+##### Convenience Historyically Interesting -### Signals +Sometimes you're creating a series of utility nodes that you don't want visible in the channel box. So you can either go.. -Maya offers a large number of callbacks for responding to native events in your code. `cmdx` wraps some of these in an alternative interface akin to Qt Signals and Slots. +```py +with cmdx.DGModifier() as mod: + reverse = mod.createNode("reverse") + multMatrix = mod.createNode("multMatrix") + mod.set_attr(reverse["isHistoricallyInteresting"], False) + mod.set_attr(multMatrix["isHistoricallyInteresting"], False) +``` -```python -import cmdx +..or use the convenience argument to make everything neat. -def onDestroyed(): - pass +```py +with cmdx.DGModifier(interesting=False) as mod: + mod.createNode("reverse") + mod.createNode("multMatrix") +``` -node = cmdx.createNode("transform") -node.onDestroyed.append(onDestroyed) +##### Convenience Try Set Attr + +Sometimes you aren't too concerned whether setting an attribute actually succeeds or not. Perhaps you're writing a bulk-importer, and it'll become obvious to the end-user whether attributes were set or not, or you simply could not care less. + +For that, you can either.. + +```py +with cmdx.DagModifier() as mod: + try: + mod.setAttr(node["attr1"], 5.0) + except cmdx.LockedError: + pass # This is OK + try: + mod.setAttr(node["attr2"], 5.0) + except cmdx.LockedError: + pass # This is OK + try: + mod.setAttr(node["attr3"], 5.0) + except cmdx.LockedError: + pass # This is OK +``` + +..or you can use the convenience `trySetAttr` to ease up on readability. + +```py + +with cmdx.DagModifier() as mod: + mod.trySetAttr(node["attr1"], 5.0) + mod.trySetAttr(node["attr2"], 5.0) + mod.trySetAttr(node["attr3"], 5.0) +``` + +##### Convenience Set Attr + +Sometimes, the attribute you're setting is connected to by another attribute. Maybe driven by some controller on a character rig? + +In such cases, the attribute cannot be set, and must set whichever attribute is feeding into it instead. So you could.. + +```py +with cmdx.DagModifier() as mod: + if node["myAttr"].connected: + other = node["myAttr"].connection(destination=False, plug=True) + mod.setAttr(other["myAttr"], 5.0) + else: + mod.setAttr(node["myAttr"], 5.0) +``` + +Or, you can use the `smart_set_attr` to automate this process. + +```py +with cmdx.DagModifier() as mod: + mod.smartSetAttr(node["myAttr"], 5.0) ``` +##### Limitations + +The modifier is quite limited in what features it provides; in general, it can only *modify* the scenegraph, it cannot query it. + +1. It cannot read attributes +2. It cannot set complex attribute types, such as meshes or nurbs curves +3. It cannot query a future hierarchy, such as asking for the parent or children of a newly created node unless you call `doIt()` first) +
### PEP8 Dual Syntax diff --git a/cmdx.py b/cmdx.py index f1129c2..ff89c9f 100644 --- a/cmdx.py +++ b/cmdx.py @@ -17,7 +17,7 @@ from maya.api import OpenMaya as om, OpenMayaAnim as oma, OpenMayaUI as omui from maya import OpenMaya as om1, OpenMayaMPx as ompx1, OpenMayaUI as omui1 -__version__ = "0.5.1" +__version__ = "0.6.0" PY3 = sys.version_info[0] == 3 From 493b345293c8fb62546bc47d90be2ee64ad63454 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Tue, 6 Apr 2021 07:42:37 +0100 Subject: [PATCH 11/17] More consistency and tests for upAxis --- cmdx.py | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/cmdx.py b/cmdx.py index ff89c9f..978504c 100644 --- a/cmdx.py +++ b/cmdx.py @@ -6157,28 +6157,45 @@ def objExists(obj): Z = "z" -if __maya_version__ >= 2019: - def upAxis(): - """Get the current up-axis as string +def upAxis(): + """Get the current up-axis as a Vector - Returns: - string: "y" for Y-up, "z" for Z-up + Examples: + >>> setUpAxis(Z) + >>> assert upAxis() == Vector(0, 0, 1) + >>> setUpAxis(Y) + >>> assert upAxis() == Vector(0, 1, 0) - """ + Returns: + string: "y" for Y-up, "z" for Z-up + + """ + + if __maya_version__ >= 2019: + return Vector(om.MGlobal.upAxis()) + + else: + if cmds.optionVar(query="upAxisDirection").lower() == "y": + return Vector(0, 1, 0) + + else: + # Maya only supports two axes + return Vector(0, 0, 1) + + +def setUpAxis(axis=Y): + """Set the current up-axis as Y or Z - return om.MGlobal.upAxis() + Tested in :func:`upAxis` + + """ - def setUpAxis(axis=Y): + if __maya_version__ >= 2019: if axis == Y: om.MGlobal.setYAxisUp() else: om.MGlobal.setZAxisUp() - -else: - def upAxis(): - return cmds.optionVar(query="upAxisDirection") - - def setUpAxis(axis=Y): + else: cmds.optionVar(stringValue=("upAxisDirection", axis)) cmds.warning( "Changing up-axis via cmdx in Maya 2019 " From 69bfc2b5f14d17b1beb86927642a4ba1347ceb89 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Tue, 6 Apr 2021 08:23:28 +0100 Subject: [PATCH 12/17] Fix undocumented argument to MGlobal.setYAxisUp --- cmdx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmdx.py b/cmdx.py index 978504c..c0eefb4 100644 --- a/cmdx.py +++ b/cmdx.py @@ -6192,9 +6192,9 @@ def setUpAxis(axis=Y): if __maya_version__ >= 2019: if axis == Y: - om.MGlobal.setYAxisUp() + om.MGlobal.setYAxisUp(True) else: - om.MGlobal.setZAxisUp() + om.MGlobal.setZAxisUp(True) else: cmds.optionVar(stringValue=("upAxisDirection", axis)) cmds.warning( From 8b0fcd8a558f428b2ec2f47ca7959760896367ac Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Tue, 6 Apr 2021 08:24:42 +0100 Subject: [PATCH 13/17] Update docstring for upAxis --- cmdx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmdx.py b/cmdx.py index c0eefb4..4eebabe 100644 --- a/cmdx.py +++ b/cmdx.py @@ -6167,7 +6167,7 @@ def upAxis(): >>> assert upAxis() == Vector(0, 1, 0) Returns: - string: "y" for Y-up, "z" for Z-up + Vector: (0, 1, 0) for Y-up, (0, 0, 1) for Z-up """ From 357c9224fafaf3b4133b02d171f57a037cdc396b Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Tue, 6 Apr 2021 13:34:05 +0100 Subject: [PATCH 14/17] Graceful exit --- run_tests.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/run_tests.py b/run_tests.py index dbba4c8..2283c99 100644 --- a/run_tests.py +++ b/run_tests.py @@ -31,7 +31,7 @@ result = nose.main( argv=argv, addplugins=[flaky.FlakyPlugin()], - + # We'll exit in our own way, # since Maya typically enjoys throwing # segfaults during cleanup of normal exits @@ -44,5 +44,8 @@ else: sys.stdout.write("Skipping coveralls\n") - # Good night Maya, you aweful segfaulter - os._exit(0 if result.success else 1) \ No newline at end of file + # Graceful exit + standalone.uninitialize() + + # Trust but verify + os._exit(0 if result.success else 1) From 356cff50b5642e21d171649c1d03ba22cd5ba5fe Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Tue, 6 Apr 2021 13:34:14 +0100 Subject: [PATCH 15/17] Add __lt__, __gt__ and forceSetAttr --- cmdx.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/cmdx.py b/cmdx.py index 4eebabe..5cf84b7 100644 --- a/cmdx.py +++ b/cmdx.py @@ -2304,6 +2304,34 @@ def __ne__(self, other): other = other.read() return self.read() != other + def __lt__(self, other): + """Is plug less than `other`? + + Examples: + >>> node = createNode("transform") + >>> node["scaleX"] < node["translateX"] + False + + """ + + if isinstance(other, Plug): + other = other.read() + return self.read() < other + + def __gt__(self, other): + """Is plug greater than `other`? + + Examples: + >>> node = createNode("transform") + >>> node["scaleX"] > node["translateX"] + True + + """ + + if isinstance(other, Plug): + other = other.read() + return self.read() > other + def __neg__(self): """Negate unary operator @@ -4717,7 +4745,7 @@ class _BaseModifier(object): """Interactively edit an existing scenegraph with support for undo/redo Arguments: - undoable (bool, optional): Put undoIt on the undo queue + undoable (bool, optional): For contexts, put undoIt on the undo queue interesting (bool, optional): New nodes should appear in the channelbox debug (bool, optional): Include additional debug data, @@ -5234,6 +5262,17 @@ def trySetAttr(self, plug, value): except Exception: pass + def forceSetAttr(self, plug, value): + if plug._mplug.isLocked: + raise LockedError("%s is locked and cannot be forced." % plug) + + if plug.connected: + # Disconnect anything connecting to this plug + self.disconnect(plug, destination=False) + self.doIt() + + self.setAttr(plug, value) + def resetAttr(self, plug): self.setAttr(plug, plug.default) @@ -5638,6 +5677,7 @@ def disconnect(self, a, b=None, source=True, destination=True): add_attr = addAttr set_attr = setAttr try_set_attr = trySetAttr + force_set_attr = forceSetAttr smart_set_attr = smartSetAttr delete_attr = deleteAttr reset_attr = resetAttr @@ -6639,7 +6679,14 @@ def __init__(self, name, fields=None, default=0, label=None, **kwargs): def create(self, cls=None): attr = super(Enum, self).create(cls) - for index, field in enumerate(self["fields"]): + for index in range(len(self["fields"])): + field = self["fields"][index] + + # Support passing in of arbitrary indexes + # E.g. fields=((0, "Box"), (3, "Sphere")) + if isinstance(field, (tuple, list)): + index, field = field + self.Fn.addField(field, index) return attr From 3e02993c7e16acfdb95a00993ee606b7e57f7950 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Tue, 6 Apr 2021 13:43:32 +0100 Subject: [PATCH 16/17] Appease the segfault gods --- run_tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/run_tests.py b/run_tests.py index 2283c99..249edef 100644 --- a/run_tests.py +++ b/run_tests.py @@ -44,8 +44,9 @@ else: sys.stdout.write("Skipping coveralls\n") - # Graceful exit - standalone.uninitialize() + if os.name == "nt": + # Graceful exit, only Windows seems to like this consistently + standalone.uninitialize() # Trust but verify os._exit(0 if result.success else 1) From 94b220bb9cd490b0c4cdafe7adab8d46c4f3edf5 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sat, 10 Apr 2021 08:26:11 +0100 Subject: [PATCH 17/17] More protection, support nice name of static attributes --- cmdx.py | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/cmdx.py b/cmdx.py index 5cf84b7..0750e88 100644 --- a/cmdx.py +++ b/cmdx.py @@ -1653,6 +1653,7 @@ def parent(self, type=None): if not type or type == self._fn.__class__(mobject).typeName: return cls(mobject) + @protected def lineage(self, type=None): """Yield parents all the way up a hierarchy @@ -1677,6 +1678,7 @@ def lineage(self, type=None): yield parent parent = parent.parent(type) + @protected def children(self, type=None, filter=om.MFn.kTransform, @@ -3111,20 +3113,51 @@ def niceName(self): """The nice name of this plug, visible in e.g. Channel Box Examples: - >>> node = createNode("transform") + >>> _new() + >>> node = createNode("transform", name="myTransform") + + # Return pairs of nice names for compound attributes + >>> node["scale"].niceName == ("Scale X", "Scale Y", "Scale Z") + True + >>> assert node["translateY"].niceName == "Translate Y" >>> node["translateY"].niceName = "New Name" >>> assert node["translateY"].niceName == "New Name" + # The nice name is preserved on scene open + >>> _save() + >>> _load() + >>> node = encode("myTransform") + >>> assert node["translateY"].niceName == "New Name" + """ # No way of retrieving this information via the API? - return cmds.attributeName(self.path(), nice=True) + + if self.isArray or self.isCompound: + return tuple( + cmds.attributeName(plug.path(), nice=True) + for plug in self + ) + else: + return cmds.attributeName(self.path(), nice=True) @niceName.setter def niceName(self, value): - fn = om.MFnAttribute(self._mplug.attribute()) - fn.setNiceNameOverride(value) + elements = ( + self + if self.isArray or self.isCompound + else [self] + ) + + for el in elements: + if el._mplug.isDynamic: + # Use setAttr as isKeyable doesn't + # persist on scene save for dynamic attributes. + cmds.addAttr(el.path(), edit=True, niceName=value) + else: + fn = om.MFnAttribute(el._mplug.attribute()) + fn.setNiceNameOverride(value) @property def default(self): @@ -5759,6 +5792,7 @@ class DagModifier(_BaseModifier): >>> mod = DagModifier() >>> parent = mod.createNode("transform", name="myParent") >>> child = mod.createNode("transform", name="myChild", parent=parent) + >>> _ = mod.createNode("transform", name="keepAlive", parent=parent) >>> mod.doIt() >>> "myParent" in cmds.ls() True @@ -5769,7 +5803,7 @@ class DagModifier(_BaseModifier): >>> mod = DagModifier() >>> _ = mod.delete(child) >>> mod.doIt() - >>> parent.child() is None + >>> parent.child().name() == 'keepAlive' True >>> "myChild" in cmds.ls() False