Structure

Repository structure

You can find the source code here. It’s structured as following:

Folder Content
arnold_subdiv_manager source code for the Arnold Subdiv Manager
scripts code to run the Arnold Subdiv Manager in and outside of Maya
tests tests for the Arnold Subdiv Manager

Classes

The classes are essential for the proper encapsulation of external dependencies. In this case, the external dependencies are Qt and the Maya API.

Encapsulating the dependency on Qt is pretty straightforward. The tool is separated into two layers, the UI layer and the business logic layer. Qt is only used in the UI Layer. The UI Layer contains no business logic and is just responsible for displaying the UI. All the business logic, which in this case is how to apply subdivision settings and load the MtoA Plugin, is separated into an extra layer. The UI layer calls the business logic layer. In this way, the business logic can be tested much easier because no UI is needed to run the test. The business logic can also be reused without UI, for example during an automated publish process.

Arnold Subdiv Manager also depends on the Maya API. This dependency is encapsulated using an abstract interface. An abstract interface defines only the methods, but no implementation. It’s described in MayaAbstraction. This way, the abstract interface does not depend on the Maya API, because the Maya API is only used in the implementation in MayaAbstractionImplementation.

SubDivUi

SubDivUI encapsulates the dependency on Qt. It only contains code related to the UI and only has a dependency on Qt. It contains no business logic. Instead, an instance of SubDivManager (part of the business logic layer) is used to execute the necessary actions. The instance of SubDivManager is passed in via dependency injection via the constructor:

class SubDivUI(QtWidgets.QWidget):

    def __init__(self, subdiv_manager, parent=None):
        # type: (SubDivManager, QtWidgets.QWidget) -> None
        super(SubDivUI, self).__init__(parent)
        self._subdiv_manager = subdiv_manager
        [...]

This way, it’s easy to write a unit test for SubDivUI. In the unit test, a mock of SubDivManager is passed instead of using a real implementation. Because the subdiv_manager variable is defined in the test function, it’s easy to assert that all methods are called correctly. There is no need to get it out of SubDivUI or access a protected member:

def test_button_click_should_activate(qtbot):
    # type: (qtbot) -> None
    subdiv_manager = MagicMock()
    ui = SubDivUI(subdiv_manager)
    
    [...]
    
    subdiv_manager.apply_subdiv_to_selection.assert_called_once()

SubDivManager

The SubDivManager is responsible for the business logic of the tool. It ensures that the MtoA Plugin gets loaded before applying the subdivision settings on the selection and ensures that it is not loaded twice. Also, the MtoA Plugin should only be loaded when some meshes are selected.

The SubDivManager contains only the business logic but does not depend directly on the Maya API. Instead, it relies on the abstract class MayaAbstraction. Here you can see the main part of the business logic of SubDivManager. Only the abstraction is used to interact with the DCC:

    [...]

    def _apply_subdiv_mode(self, subdiv_mode):
        # type: (SubDivMode) -> None
        meshes = self._maya_abstraction.get_meshes_in_selection()

        if self._should_load_arnold_plugin(meshes):
            self._maya_abstraction.load_arnold_plugin()
        
        for mesh in meshes:
            self._maya_abstraction.apply_subdiv_attr(mesh, subdiv_mode)
    
    def _should_load_arnold_plugin(self, meshes):
        # type: (List[Mesh]) -> bool
        return bool(meshes) and not self._maya_abstraction.is_arnold_plugin_loaded()

An instance of a MayaAbstraction implementation is passed in via dependency injection to the constructor:

class SubDivManager(object):
    def __init__(self, maya_abstraction):
        # type: (MayaAbstraction) -> None
        self._maya_abstraction = maya_abstraction
    
    [...]

Using MayaAbstraction makes it straight forward to test the logic contained in SubDivManager. A mock of MayaAbstraction can be easily prepared and passed to the constructor of SubDivManager. On the mock, the return values can be set in the required way. In this test, it should be ensured that the MtoA Plugin gets loaded when applying the subdivision. So the mock is prepared to return False for any call to is_arnold_plugin_loaded().

def test_should_load_arnold_plugin_before_applying_subdiv():
    # type: () -> None
    maya_abstraction = MagicMock()
    maya_abstraction.is_arnold_plugin_loaded.return_value = False
    subdiv_manager = SubDivManager(maya_abstraction)

    [...]

With the prepared mock, apply_subdiv_to_selection() can now be called. Since we have the mock available as a variable, it’s easy to assert that load_arnold_plugin() was called on the abstraction:

    [...]

    subdiv_manager.apply_subdiv_to_selection()

    maya_abstraction.load_arnold_plugin.assert_called_once()

Testing the logic against the real Maya implementation would be way more unpleasant. First, all the tests would have to run inside Maya. But this makes the testing process much slower because Maya takes a while to load. Since unit tests are required to run very fast, this is not an option. Even if Maya loaded quickly, the scene would have to be prepared for the expected return values. To the correct loading of the MtoA plugin, the MtoA plugin would have to be unloaded and loaded. This can take some time. Reloading and Unloading of the MtoA plugin can also decrease the stability of Maya. With this approach, the DCC can just be mocked in tests.

Mesh

SubDivManager needs a way to represent a mesh. To keep the implementation of MayaAbstraction as simple as possible, it provides only a method to set the subdivision attribute for a single mesh. To set the subdivision attribute for a selection, a loop must be made in SubDivManager. SubDivManager needs a way to represent a mesh without a dependency to the Maya API. The simplest way to achieve this is to store the path to the mesh in the scene as a string. The string is not used directly and wrapped in the simple Mesh class. This makes the code more expressive since type hints are used and the type information is visible to the reader. The attrs package generates a constructor and several other methods and makes creating small classes really easy:

@attr.s(frozen=True)
class Mesh(object):
    name = attr.ib(type=str)

Since Python 3.7 there is with @dataclass also a way to archive this available in the Python Standart Library, but Arnold Subdiv Manager has to support Python 2.7.

SubDivMode

SubDivMode is an enum and represent the possible subdivision modes. An enum is used here instead of a string or something else. This way, the code is more expressive since type hints are used and the type information is visible to the reader.

MayaAbstraction

MayaAbstraction is an abstract class, so the methods in this class have no implementation. It does not depend on the Maya API, only implementations like MayaAbstractionImplementation do. This way, code using MayaAbstraction does not depend directly on the Maya API, which is easier to test. An extra class for the abstract interface is used so that type hints can be used safely.

Abstract classes should not be instantiated, since they have abstract methods without implementation. To prevent instantiation, ABCMeta is set as __metaclass__ and methods with no implementation are marked with @abstractmethod.

class MayaAbstraction(object):
    __metaclass__ = ABCMeta

    @abstractmethod
    def load_arnold_plugin(self):
        # type: () -> None
        pass
    [...]

MayaAbstractionImplementation

MayaAbstractionImplementation is the concrete implementation of MayaAbstraction. It’s the only part in Arnold Subdiv Manager that uses the Arnold API. The MayaAbstraction is defined in a way that they are straightforward to implement. Most methods are simple one liners like this:

    def load_arnold_plugin(self):
        # type: () -> None
        pm.loadPlugin("mtoa")

Since the implementations are straightforward, it is not necessary to write many tests for them. MayaAbstractionImplementation is only tested with integration tests that run inside Maya. Since they require Maya, they would fail outside of Maya when executed. Pytest allows tests to be marked with markers and skip them if a specific condition is true. The integration tests for MayaAbstractionImplementation are marked with @maya_only to ensure they are only executed inside of Maya. Since these tests require the import of some Maya APIs, these imports have to be done locally in the test functions, because otherwise these imports would fail during test collection when the tests are executed outside of Maya. To check if Maya is available, its checked if maya.cmds can be imported:

def has_maya():
    # type: () -> bool
    try:
        import maya.cmds as cmds
        return True
    except ImportError:
        pass
    return False

maya_only = pytest.mark.skipif(not has_maya(), reason="requires Maya")
Last modified August 24, 2020