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")