Python Contexts in Maya

Python’s contexts are a powerful feature and if you’re writing any kind of pipeline or animation tools, you should know about them.

Maya has a number of useful applications for the context. If you’ve spent any time working in it, you have no doubt noticed the tendency of the global selectionList to change at inopportune times. Many Maya commands have flags to prevent the selection of new objects, but Python gives us a much nicer way. Consider the following:


from contextlib import contextmanager
import maya.OpenMaya as om
@contextmanager
def keepSelection():
    # setup
    sel = om.MSelectionList()
    om.MGlobal.getActiveSelectionList(sel)

    yield

    #cleanup
    om.MGlobal.setActiveSelectionList(sel)

The context is ready to maintain Maya’s selection when running any code in the block.


import maya.cmds as cmds
cube = cmds.polyCube()
cmds.select(cube)
with keepSelection():
    loc = cmds.spaceLocator()
    cmds.select(loc)

print(cmds.ls(sl=1))

If all has gone well, you should see the current selection printed in the scriptEditor: [u’pCube1′, u’polyCube1′]. The original selection is restored when the code within the ‘with’ block completes.

Background Info

If you’ve ever used the ‘with’ keyword, you’ve used a context. If you want to know why they’re cool, you need to know a bit about resource acquisition is initialization. The key concept is the acquisition and release of resources tied to object lifetimes. The resources in question are things like memory, file descriptors – anything that is limited and used by the computer. File descriptors are the classic example, as operating systems have limited numbers of descriptors that are allocated when files are opened for reading and writing. In Python, files are opened with the open keyword:


def getData(fileName):
    fl = open('myFile.txt')
    data = fl.read()
    fl.close()
    return data

Open and shut, except for a couple of things. If there is an error when reading the data, fl.close may not get called – or one could forget to call close(). This means that the file descriptor will remain open until the Python application space is shut down. If you’re running this code in Maya, that could be quite a while. If the function returns before close() is called, the file remains open.

The RAII idiom ties the acquisition of the file descriptor to the lifetime of an object. This works great in C++, where objects live and die in very predictable ways. For example, in C++ you can open a file with std::ifstream:


#include <string>
#include <fstream>
#include <streambuf>

int accessFile(const std::string& name, std::string& data) {
    data.clear();
    std::ifstream ip(name.c_str());
    if(ip) {
        data.assign((std::istreambuf_iterator<char>(t)),
                 std::istreambuf_iterator<char>());
        return 1;
    }
    return 0;
}

When accessFile goes out of scope, ip is deleted, and it’s deletion causes the file handle to be released. Even if an exception occurs, when the ip instance is destroyed by any means, the file closes.

In Python and other garbage collected languages, the lifetime of ip is not clear. When the Python function returns, ip is unbound, but the data structure it pointed to exists until the Python garbage collector deletes it. As a result, even if the deletion of the Python file object released the file descriptor, it would do so at an unpredictable time compared to the C++ version.

Enter the Context

The context is an object that implements an __enter__() and an __exit__() method – these methods provide initialize and cleanup functions when the code enters the scope of a ‘with’ statement. As these are functions that get called, it is known where and when they get called.

Now the proper way to open a file object for reading or writing is to use the ‘with’ statement:


with open('myFile.txt') as ip:
    data = ip.read()

The file object context protects against exceptions – so the file handle is guaranteed to close when the context closes no matter what.

The contextlib.contextmanager decorator, as used in the keepSelection context above. The decorator defines an iterator that performs the setup work, then ‘yields’ a result (save selection yields None). When the ‘with’ statement is exited, the code after the yield statement is executed.

For a simpler, non-Maya example, you can try the following:


@contextmanager
def testContext():
    print('Enter')
    yield
    print('Exit')

with testContext():
    print(' Printing')

# Result
Enter
 Printing
Exit

Handling Exceptions

The contextmanager generator is not exception-proof by default, but often it needs to be able to handle itself. Consider the previous example, but an exception is thrown in the ‘with’ block:


@contextmanager
def keepSelection():
    # setup
    sel = om.MSelectionList()
    om.MGlobal.getActiveSelectionList(sel)
    raise RuntimeError
    yield

    #cleanup
    om.MGlobal.setActiveSelectionList(sel)

Run this with the cube/locator code from earlier, and the runtime error will show up in the scriptEditor, and the locator will be selected, meaning the cleanup code did not run. This may or may not be desirable, but here’s how to handle exceptions so you can make the call:


@contextmanager
def keepSelection():
    # setup
    sel = om.MSelectionList()
    om.MGlobal.getActiveSelectionList(sel)
    raise RuntimeError
    try:
        yield
    finally:
        pass

    #cleanup
    om.MGlobal.setActiveSelectionList(sel)

Now the cleanup will run no matter what exception is encountered. The exception will still raise – code can be added to catch the exception based on need.

I am planning to do a follow up post with some more info and hopefully useful decorators. In the meantime, I’ll leave you with one I find interesting – it’s not a full implementation, but a decent start. This one makes raising exceptions optional, should they occur:


@contextmanager
def tempNamespace(ns, stopOnError=False):
    cur = cmds.namespaceInfo(cur=1)
    cmds.namespace(set=':')
    cmds.namespace(add=ns)
    cmds.namespace(set=ns)
    try:
        yield
    except Exception,e:
        if stopOnError:
            raise
    cmds.namespace(set=':')
    cmds.namespace(mv=(ns, ':'), f=1)
    cmds.namespace(rm=ns, f=1)

 

Advertisements
Python Contexts in Maya

Iterator Generators and Maya

Iteration is a fundamental concept of computer science, and Python’s iterator generators make it a snap to organize and re-use iteration. I find that I am frequently iterating through large hierarchical structures, both ‘up’ and ‘down’ them looking for nodes with specific qualities.

In Maya, this is often iterating through the DAG, which is Maya’s version of a scengraph. Each level of the DAG can have one parent (for transforms, not shapes) and multiple children. If you are traversing the DAG looking for items, say in a character’s skeleton, Python iteration could be what you’re looking for.

In PyMel, you can grab a PyNode and ask for its parent with getParent(). I find, however, that I often need to iterate farther up the DAG looking for an ancestor that has a particular name or attribute. This could be accomplished by multiple calls to the getParent method of each node, but that can be cumbersome to do in code, and is harder to do in when using list comprehensions. Fortunately, a simple iterator generator can be whipped up in no time:


def parentIter(pnode):
    p = pnode.getParent()
    while p:
        yield p
        p = p.getParent()

Now we can hand this a PyNode (or anything with a getParent method) and it’ll run all the way up the DAG. This is handy if you have a schema where you are parenting a character’s skeleton under a root transform.

for t in parentIter(PyNode('someJoint')):
    if t.type() == 'transform':
    # do something here

This is pretty useful – I often will add a bit of sugar to make life easier. Consider a function that expects to operate on a character – it takes any node that’s part of the character, but needs to get some info off of the root node. Often, you’d need to check to see if the caller passed in the root node itself, or any of the joints. You would need something like this:


def getRoot(node):
    if node.type() == 'transform' and node.hasAttr('info'):
        return node
    for nd in parentIter(node):
        if node.type() == 'transform' and node.hasAttr('info'):
            return node

In order to prevent duplicating the testing part (seeing if a node is a transform and has an attribute called info), the parentIter function can be modified as follows:


def parentIter(pnode, inclusive=False):
    if inclusive:
        yield pnode
    p = pnode.getParent()
    while p:
        yield p
        p = p.getParent()

Now, the iteration can be a bit simpler:


def getRoot(node):
    for nd in parentIter(node, inclusive=True):
        if node.type() == 'transform' and node.hasAttr('info'):
            return node

Eliminating those extra lines can reduce visual clutter and potential error. Also, if you need to update the line that does the filtering, you only have to change it in one place.

Child iteration can be just as simple if you don’t care about the order and you don’t need to skip branches of a hierarchy:


def childIter(pnode, inclusive=False):
    if inclusive:
        yield pnode
    for ch in pnode.getChildren():
        yield ch
        for gch in childIter(ch):
            yield gch

Again, the inclusive keyword is used to simplify the iteration. I usually keep the keyword optional as it seems to blur the line between what you might expect ‘Give me all the nodes under x‘ as opposed to ‘Give me x and all the nodes underneath it‘. I find in my work plenty of use cases for either, so keeping the arg as a convenience seems efficient and clear.

One thing to note about these generators – if you need a list of everything the generator would return, you need to use a list constructor to capture it, i.e.:


# get all the nodes that are ancestors in a list
list(parentIter(node))

Failure to do so will often result in the calling code erroring out with the generator object:


# Error: AttributeError: file line 1: 'generator' object has no attribute 'append' #

 

Iterator Generators and Maya

Visual Python Error Reporting

3d pipeline application development for a studio seems very much like developing and maintaining web services for a website. You have a large user base and you do not expect those users to be very technically inclined. Animators are, after all, necessarily more interested in creating interesting performances than the somewhat obscure minutiae of node graphs and connection types. This is not to say there are not technical animators out there – there are many very good ones, to be sure. A large studio, tho, invests some time and money in providing more technical support to maximize animator performance time and minimize cursing time. We have, as yet, no solution to minimize animator drinking time, however.

The tricky part is that modern 3d application software provides almost too many options for the non or semi technical to cause mischief. The design and implementation of animation tools is a constant battle to anticipate an infinite number of states an artist’s animation scene can be in.

So when, in the course of human events, it becomes necessary for animation tools to fail, it would be optimal if they were to fail in a useful way. Many artistic types pay no attention to the red-flash of the status bar familiar to many TDs (if they even have it displayed), and the Script Editor is as foreign as Chrome’s developer console.

Fortunately, if you’re using Python, you have some tools available to help with better error reporting. A clear winner in this area is the decorator. Python decorators wrap functions and re-bind them in the Python interpreter, allowing the re-use of code with minimal intrusion. One practical application of this is a decorator that can provide useful information about an Exception.


from functools import wraps

def displayException(f):
    # copy function info to decorator
    @wraps(f)
    def _wrapped(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except Exception,e:
            # try to import PySide - if you can't, bail            
            try:
                from PySide import QtGui
                # check to see if the application is running - if not,
                # don't launch ui
                if QtGui.QApplication.instance():
                    tmp = QtGui.QMessageBox.critical(None, 'Python Exception', str(e))
            except ImportError,e:
                pass
                
            # allow the exception to propogate up
            raise
    return _wrapped

Now you can use this to wrap any function you have and, if it fails, it will at least it will present a error dialog to the user. I recommend using it at only at your higher-level entry point functions. If you use this in lower-level loops, you run the risk of popping up hundreds of these.


@displayException
def myMainFunction():
    """This does some big operation - like building a rig or publishing a
    file
    """
    pass

This code uses functools.wraps to update the returned decorator with the function’s doc string and other attributes. Also, I check to see if PySide can even be imported – my use case for this would be Maya, but this will work in any Python code where PySide may or may not be available. Also, the code checks to see if the QApplication is running – this is good practice, as launching a UI when Maya is running in batch mode could be a bad idea.

Visual Python Error Reporting