Python Scripting for Maya Artists 2.0

I’ve updated my semi-popular pages on Python Scripting for Maya Artists. The material was originally written in 2009 and the pdf has been downloaded over 18,000 time so it was all due for an update. Updates include:

  • Various cleanup on examples and explanations.
  • New material on string formatting.
  • New material on directory traversal.
  • New material on docstring formats.
  • New section on code conventions and PEP8.
  • New section on writing clean code.
  • PDF has been cleaned up and reformatted.

Unit Testing in Maya (Part 2)

  • Edited: 6/18/2016: Updated RollbackImporter to support relative imports and “from package import module” syntax.

In my last post, I went over writing scripts that can be used to write unit tests for Maya tools and then run those tests from the commandline outside of Maya. In this post, I’ll go over running those tests interactively in Maya. Being able to run tests interactively in Maya will greatly speed up debug times when tracking down issues because you’ll be able to see what is going on in your tests. To make the process more user-friendly, I’ll also add a UI to help execute the tests. Again, all of this code is available on my Github.


The interface is written in PySide and uses a QTreeView to display a derived QAbstractItemModel holding the discovered test data. Since the UI is pretty standard PySide code, I will only go over the code related to actually running the tests. The UI provides three different methods of running tests as a convenience to the user: run all tests, run selected tests, and run failed tests. To run all the tests, we can use the get_tests function mentioned in the previous post.

def run_all_tests(self):
    """Callback method to run all the tests found in MAYA_MODULE_PATH."""
    test_suite = unittest.TestSuite()
    self.model.run_tests(, test_suite)

The first line resets what I’ll call the rollback importer. It is used to reload code updates without having to rely on sprinkling reload calls throughout your code which is typically bad form. I’ll go over the rollback importer shortly. Next, we populate a TestSuite with all tests found in the paths of MAYA_MODULE_PATH by calling our get_tests function. Then we clear our output console and run the tests.

The rollback importer helps to create a better testing workflow by getting the latest version of the code without having to reload anything. It allows for a iterative update/test workflow without having to restart Maya or insert several reload statements in your code. I learned about this technique from PyUnit.

import __builtin__
class RollbackImporter(object):
    """Used to remove imported modules from the module list.

    This allows tests to be rerun after code updates without doing any reloads.

    def run_tests(self):
        if self.rollback_importer:
        self.rollback_importer = RollbackImporter()
    def __init__(self):
        """Creates an instance and installs as the global importer."""
        self.previous_modules = set(sys.modules.keys())

    def uninstall(self):
        for modname in sys.modules.keys():
            if modname not in self.previous_modules:
                # Force reload when modname next imported

The rollback importer temporarily replaces the Python import function to store all the modules imported from that point forward which it can then use to remove the loaded modules from the sys.modules dictionary. As a result, the module will be import fresh each time it is imported. Users just have to run the rollback importer (which runs by opening the UI) before importing any other modules.

The PySide data model class contains its own run_tests method to run all the specified tests.

def run_tests(self, stream, test_suite):
    """Runs the given TestSuite.

    @param stream: A stream object with write functionality to capture the test output.
    @param test_suite: The TestSuite to run.
    runner = unittest.TextTestRunner(stream=stream, verbosity=2, resultclass=mayaunittest.TestResult)
    runner.failfast = False
    runner.buffer = mayaunittest.Settings.buffer_output
    result =

    self._set_test_result_data(result.errors, TestStatus.error)
    self._set_test_result_data(result.skipped, TestStatus.skipped)

    for test in result.successes:
        node = self.node_lookup[str(test)]
        index = self.get_index_of_node(node)
        self.setData(index, 'Test Passed', QtCore.Qt.ToolTipRole)
        self.setData(index, TestStatus.success, QtCore.Qt.DecorationRole)

def _set_test_result_data(self, test_list, status):
    """Store the test result data in model.

    @param test_list: A list of tuples of test results.
    @param status: A TestStatus value."""
    for test, reason in test_list:
        node = self.node_lookup[str(test)]
        index = self.get_index_of_node(node)
        self.setData(index, reason, QtCore.Qt.ToolTipRole)
        self.setData(index, status, QtCore.Qt.DecorationRole)

The code is similar to the run_tests function in previous post except this time we are capturing the result to display in the UI. You’ll see some code related to looking up the QModelIndex of an item in the data model given a test object. This is used to aid in updating the UI with the proper result per test. The stream that is passed in is used to capture the output of the tests and display them in the output console (a QTestEdit) with color output.

class TestCaptureStream(object):
    """Allows the output of the tests to be displayed in a QTextEdit."""
    success_color = QtGui.QColor(92, 184, 92)
    fail_color = QtGui.QColor(240, 173, 78)
    error_color = QtGui.QColor(217, 83, 79)
    skip_color = QtGui.QColor(88, 165, 204)
    normal_color = QtGui.QColor(200, 200, 200)

    def __init__(self, text_edit):
        self.text_edit = text_edit

    def write(self, text):
        """Write text into the QTextEdit."""
        # Color the output
        if text.startswith('ok'):
        elif text.startswith('FAIL'):
        elif text.startswith('ERROR'):
        elif text.startswith('skipped'):


    def flush(self):

To run a given test interactively, we will want to be able to disable the functionality of creating a new scene after each test. That way we can run a test and see actually see what it did in Maya.

class MayaTestRunnerDialog(MayaQWidgetBaseMixin, QtGui.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MayaTestRunnerDialog, self).__init__(*args, **kwargs)
        # snip...
        action = menu.addAction('New Scene Between Test')
        action.setToolTip('Creates a new scene file after each test.')

# In
def set_file_new(value):
    """Set whether a new file should be created after each test.

    @param value: True or False
    Settings.file_new = value

Running the selected tests is similar to running all the tests except we can now use the test parameter of the get_tests function to only load specific tests.

def run_selected_tests(self):
    """Callback method to run the selected tests in the UI."""
    test_suite = unittest.TestSuite()

    indices = self.test_view.selectedIndexes()
    if not indices:

    # Remove any child nodes if parent nodes are in the list.  This will prevent duplicate tests from being run.
    paths = [index.internalPointer().path() for index in indices]
    test_paths = []
    for path in paths:
        tokens = path.split('.')
        for i in range(len(tokens) - 1):
            p = '.'.join(tokens[0:i+1])
            if p in paths:

    # Now get the tests with the pruned paths
    for path in test_paths:
        mayaunittest.get_tests(test=path, test_suite=test_suite)

    self.model.run_tests(, test_suite)

Also it is nice to be able to run all failed tests. It is almost identical to run_all_test except we only use the tests with fail or error statuses.

def run_failed_tests(self):
    """Callback method to run all the tests with fail or error statuses."""
    test_suite = unittest.TestSuite()
    for node in self.model.node_lookup.values():
        if isinstance(node.test, unittest.TestCase) and node.get_status() in {, TestStatus.error}:
            mayaunittest.get_tests(test=node.path(), test_suite=test_suite)
    self.model.run_tests(, test_suite)

With all the above code, we now have a way to interactively run our unit tests inside of Maya. Also all of this code is available for free on Github.

Unit Testing in Maya

Unit testing is a valuable tool that a lot of studios fail to implement. Unit testing can save a great amount of time and energy (and therefore money) by allowing developers, tool writers, and even content creators to catch broken deliverables before passing them downstream in the pipeline. The lack of unit testing with TD’s is most likely due to one of the following:

  • The person doesn’t know what unit testing is.
  • The person has heard of unit testing but has no idea how to implement it.
  • The person is in production and believes there is no time for creating unit tests.
  • The person knows what unit tests are and just doesn’t want to do them.

In this post, I am going to describe unit testing as it relates to assets and Python tools created for Maya. For those that don’t know, unit testing is pretty much a bunch of Python functions that you write that verify your tool is doing what it is supposed to be doing. For example, if you write an ik/fk switch tool, one unit test could be to load a rig, pose the arm in ik, switch to fk, and then check to see if the arm is in the same place. Another example is if you write a blendShape exporter, you can write tests to make sure the shapes are properly recreated when you import them into a new scene. Unit testing gives developers the confidence to update their code and assets with the certainty that their updates are not breaking any existing functionality.

If you are writing any semi-complex tool that is being used in production by other users, it is extremely worth while to write unit tests for your tool. Not only will it make it easier for other developers to contribute to your work, it also reduces the amount of regressions, or updates that break other parts of your tool, from happening. In today’s bigger studios, you will often be tasked with updating or maintaining systems that you did not write. If there are no testing suites created for the tool, you have no way of knowing if the updates you do will break some part of the system. If I were in the heat of production, I would rather spend a few hours over a day or two writing tests for a tool, then spending days over an entire production constantly chasing down products of buggy tools.

I’m not going to go over the intricacies of using Python’s unittest package as there are already many resources describing how to use unittest. I will however describe it’s relationship with Maya and how you can use it inside of Maya.

All of the code described in this post is available on my Github.

To facilitate writing tests for Maya, it is useful to implement a subclass of unittest.TestCase. The derived class will add helpful functionality such as automatic loading/unloading of plug-ins, and deleting temporary files:

class TestCase(unittest.TestCase):
    """Base class for unit test cases run in Maya.

    Tests do not have to inherit from this TestCase but this derived TestCase contains convenience
    functions to load/unload plug-ins and clean up temporary files.

    # Keep track of all temporary files that were created so they can be cleaned up after
    # all tests have been run
    files_created = []

    # Keep track of which plugins were loaded so we can unload them after all tests have been run
    plugins_loaded = set()

    def tearDownClass(cls):
        super(TestCase, cls).tearDownClass()

    def load_plugin(cls, plugin):
        """Load the given plug-in and saves it to be unloaded when the TestCase is finished.

        @param plugin: Plug-in name.
        cmds.loadPlugin(plugin, qt=True)

    def unload_plugins(cls):
        # Unload any plugins that this test case loaded
        for plugin in cls.plugins_loaded:
        cls.plugins_loaded = []

    def delete_temp_files(cls):
        """Delete the temp files in the cache and clear the cache."""
        # If we don't want to keep temp files around for debugging purposes, delete them when
        # all tests in this TestCase have been run
        if Settings.delete_files:
            for f in cls.files_created:
                if os.path.exists(f):
            cls.files_create = []

    def get_temp_filename(cls, file_name):
        """Get a unique filepath name in the testing directory.

        The file will not be created, that is up to the caller.  This file will be deleted when
        the tests are finished.
        @param file_name: A partial path ex: 'directory/somefile.txt'
        @return The full path to the temporary file.
        temp_dir = Settings.temp_dir
        if not os.path.exists(temp_dir):
        base_name, ext = os.path.splitext(file_name)
        path = '{0}/{1}{2}'.format(temp_dir, base_name, ext)
        count = 0
        while os.path.exists(path):
            # If the file already exists, add an incrememted number
            count += 1
            path = '{0}/{1}{2}{3}'.format(temp_dir, base_name, count, ext)
        return path

With these added utility methods, you can write tests that verify that your plug-ins can be unloaded properly and that any temporary files are cleaned up:

import maya.cmds as cmds
from cmt.test import TestCase
class SampleTests(TestCase):
    def setUpClass(cls):

    def test_export_blendshape(self):
        base = cmds.polySphere(r=5)[0]
        target = cmds.polySphere(r=10)[0]
        blendshape = cmds.blendShape(target, base)[0]
        file_path = self.get_temp_filename('blendshape.shapes')
        cmds.myCustomBlendShapeExporter(blendshape, path=file_path)

Now that we can write tests, I’ll go over how to run them from outside of Maya from the commandline without opening the Maya interface. This is useful to quickly run tests without having to open up the Maya application.

def main():
    parser = argparse.ArgumentParser(description='Runs unit tests for a Maya module')
    parser.add_argument('-m', '--maya',
                        help='Maya version',
    pargs = parser.parse_args()
    mayaunittest = os.path.join(CMT_ROOT_DIR, 'scripts', 'cmt', 'test', '')
    cmd = [mayapy(pargs.maya), mayaunittest]
    if not os.path.exists(cmd[0]):
        raise RuntimeError('Maya {0} is not installed on this system.'.format(pargs.maya))

    maya_app_dir = create_clean_maya_app_dir()
    # Create clean prefs
    os.environ['MAYA_APP_DIR'] = maya_app_dir
    # Clear out any MAYA_SCRIPT_PATH value so we know we're in a clean env.
    os.environ['MAYA_SCRIPT_PATH'] = ''
    # Run the tests in this module.
    os.environ['MAYA_MODULE_PATH'] = CMT_ROOT_DIR
    except subprocess.CalledProcessError:

if __name__ == '__main__':

The above code is the entry point to a Python script that runs the tests from the command line. You’ll notice that in order for the tests to be run, we need to use the mayapy Python interpreter that ships with Maya. The above script will usually be executed using the system install of Python and then a subprocess will be spawned using the mayapy of the specified Maya version:

def get_maya_location(maya_version):
    """Get the location where Maya is installed.

    @param maya_version The maya version number.
    @return The path to where Maya is installed.
    if 'MAYA_LOCATION' in os.environ.keys():
        return os.environ['MAYA_LOCATION']
    if platform.system() == 'Windows':
        return 'C:/Program Files/Autodesk/Maya{0}'.format(maya_version)
    elif platform.system() == 'Darwin':
        return '/Applications/Autodesk/maya{0}/'.format(maya_version)
        location = '/usr/autodesk/maya{0}'.format(maya_version)
        if maya_version < 2016:
            # Starting Maya 2016, the default install directory name changed.
            location += '-x64'
        return location

def mayapy(maya_version):
    """Get the mayapy executable path.

    @param maya_version The maya version number.
    @return: The mayapy executable path.
    python_exe = '{0}/bin/mayapy'.format(get_maya_location(maya_version))
    if platform.system() == 'Windows':
        python_exe += '.exe'
    return python_exe

Also, we want to run the tests in a predictable and clean environment. It is very hard to track down bugs when external environment changes can affect your tools. Therefore, we will be creating a fresh directory that contains a clean Maya.env and Maya preferences which is referred to by the MAYA_APP_DIR environment variable:

def create_clean_maya_app_dir():
    """Creates a copy of the clean Maya preferences so we can create predictable results.

    @return: The path to the clean MAYA_APP_DIR folder.
    app_dir = os.path.join(CMT_ROOT_DIR, 'tests', 'clean_maya_app_dir')
    temp_dir = tempfile.gettempdir()
    if not os.path.exists(temp_dir):
    dst = os.path.join(temp_dir, 'maya_app_dir{0}'.format(uuid.uuid4()))
    shutil.copytree(app_dir, dst)
    return dst

Before running the mayapy subprocess, we set the three Maya environment variables MAYA_APP_DIR, MAYA_SCRIPT_PATH, and MAYA_MODULE_PATH. The MAYA_APP_DIR is set to the clean preferences directory described above. The MAYA_SCRIPT_PATH is cleared just in case there is an existing value set. Current values of MAYA_SCRIPT_PATH could run userSetup files located in other places that we do not want in our testing environment. The MAYA_MODULE_PATH is currently set to the module that this code is located in (available on my Github). However, this could be updated to load all the modules stored at some location in your development environment or shared network location.

The mayapy subprocess gets spawned and calls the following function:

def run_tests_from_commandline():
    """Runs the tests in Maya standalone mode.

    This is called when running cmt/bin/ from the commandline.
    import maya.standalone

    # Make sure all paths in PYTHONPATH are also in sys.path
    # When a maya module is loaded, the scripts folder is added to PYTHONPATH, but it doesn't seem
    # to be added to sys.path. So we are unable to import any of the python files that are in the
    # module/scripts folder. To workaround this, we simply add the paths to sys ourselves.
    realsyspath = [os.path.realpath(p) for p in sys.path]
    pythonpath = os.environ.get('PYTHONPATH', '')
    for p in pythonpath.split(os.pathsep):
        p = os.path.realpath(p) # Make sure symbolic links are resolved
        if p not in realsyspath:
            sys.path.insert(0, p)


    # Starting Maya 2016, we have to call uninitialize
    if float(cmds.about(v=True)) >= 2016.0:

To run the tests, we'll first initialize maya.standalone. Then there seems to be buggy behavior that we'll account for where scripts directories of Maya modules do not properly get added to the sys.path even though they get added to the PYTHONPATH. Once Maya is initialized and ready to go we can run the tests:

def run_tests(directories=None, test=None, test_suite=None):
    """Run all the tests in the given paths.

    @param directories: A generator or list of paths containing tests to run.
    @param test: Optional name of a specific test to run.
    @param test_suite: Optional TestSuite to run.  If omitted, a TestSuite will be generated.
    if test_suite is None:
        test_suite = get_tests(directories, test)

    runner = unittest.TextTestRunner(verbosity=2, resultclass=TestResult)
    runner.failfast = False
    runner.buffer = Settings.buffer_output

Before we can run the tests, we first have to find them. Finding the tests can be tricky if there is no agreed upon standard. I will assume all tests will be located in a "tests" directory of each Maya module in the MAYA_MODULE_PATH:

def get_tests(directories=None, test=None, test_suite=None):
    """Get a unittest.TestSuite containing all the desired tests.

    @param directories: Optional list of directories with which to search for tests.  If omitted, use all "tests"
    directories of the modules found in the MAYA_MODULE_PATH.
    @param test: Optional test path to find a specific test such as 'test_mytest.SomeTestCase.test_function'.
    @param test_suite: Optional unittest.TestSuite to add the discovered tests to.  If omitted a new TestSuite will be
    @return: The populated TestSuite.
    if directories is None:
        directories = maya_module_tests()

    # Populate a TestSuite with all the tests
    if test_suite is None:
        test_suite = unittest.TestSuite()

    if test:
        # Find the specified test to run
        directories_added_to_path = [p for p in directories if add_to_path(p)]
        discovered_suite = unittest.TestLoader().loadTestsFromName(test)
        if discovered_suite.countTestCases():
        # Find all tests to run
        directories_added_to_path = []
        for p in directories:
            discovered_suite = unittest.TestLoader().discover(p)
            if discovered_suite.countTestCases():

    # Remove the added paths.
    for path in directories_added_to_path:

    return test_suite

def maya_module_tests():
    """Generator function to iterate over all the Maya module tests directories."""
    for path in os.environ['MAYA_MODULE_PATH'].split(os.pathsep):
        p = '{0}/tests'.format(path)
        if os.path.exists(p):
            yield p

The above code populates a unittest.TestSuite object with all the tests found in the MAYA_MODULE_PATH directories. There is also additional parameters to just load individual tests which we will revisit in a future post.

In the run_tests function above, we pass in a custom result class to the TextTestRunner constructor. The custom TestResult class provides functionality such as disabling logging during the test run as well as suppressing script editor output. Often it is useful to just see the test results without all the script editor clutter. The custom TestResult class also creates a new scene after each test is executed. When writing tests, you want each test to be totally independent from each other. Creating a new scene file after each test helps ensure the tests are independent.

class TestResult(unittest.TextTestResult):
    """Customize the test result so we can do things like do a file new between each test and suppress script
    editor output.
    def startTestRun(self):
        """Called before any tests are run."""
        super(TestResult, self).startTestRun()
        if Settings.buffer_output:
            # Disable any logging while running tests. By disabling critical, we are disabling logging
            # at all levels below critical as well

    def stopTestRun(self):
        """Called after all tests are run."""
        if Settings.buffer_output:
            # Restore logging state
        if Settings.delete_files and os.path.exists(Settings.temp_dir):

        super(TestResult, self).stopTestRun()

    def stopTest(self, test):
        """Called after an individual test is run.

        @param test: TestCase that just ran."""
        super(TestResult, self).stopTest(test)
        if Settings.file_new:
            cmds.file(f=True, new=True)

With all that code in place, you should be able to run your tests from the command line and see which tests pass and which tests fail:

D:\dev\maya\cmt\bin>python --maya 2016
test_another_thing (test_simple.SimpleTests) ... FAIL
test_error (test_simple.SimpleTests) ... ok
test_skip (test_simple.SimpleTests) ... skipped 'Skip this for now'
test_something (test_simple.SimpleTests) ... ok

FAIL: test_another_thing (test_simple.SimpleTests)
Traceback (most recent call last):
  File "D:\dev\maya\cmt\tests\", line 13, in test_another_thing
AssertionError: False is not true

Ran 4 tests in 0.031s

FAILED (failures=1, skipped=1)

All of this code is available to look at in my new cmt (Chad's Maya Tools...I know, not very creative) repository on Github. Also, I would like to thank my former colleague, Sivanny, for providing some of the ideas described in this post. In a future post, I will go over the creation of a user interface to run tests interactively inside of Maya.

New Job at Electronic Arts

I have officially joined the game industry! After 8 years in the film and feature animation industry, I decided to switch over and try the game industry. I accepted a Craft Director position at Visceral Games of Electronic Arts where I will be helping with character and cinematics technology. Lots of exciting challenges await!

Packt’s 2000th Title Sale


Packt Publishing has published its 2000th title and is having a sale of buy one, get one free across their whole eBook catalog. Use the link above to take advantage of the sale. There’s a lot of nice tech books in there with some good material if you’re in the mood to learn something new.

CGCircuit Request a Lesson

I have some lessons in mind but I am curious as to what sort of lessons people are interested in.

Possible topics could be:

  • Writing a Wrap Deformer
  • Writing Mesh Collision Deformer
  • Writing a Production Pipeline with Django
  • Facial Rigging for VFX
  • Advanced Maya API
  • PySide for VFX and Animation

Comment on this post and suggest a lesson!