Building the pmhelp function
Python has a built-in help
function that, when paired with the REPL described previously, is very useful for rapid exploration.
>>> help Type help() for interactive help, or help(object) for help about object.
However, help
can be difficult to use with PyMEL (along with many other libraries). The documentation may be missing from the actual object, or may be defined somewhere else. Commonly, the documentation is too verbose to read comfortably in a terminal window. And in the case of a GUI Maya session, it is just more convenient to have documentation in your browser than in the Script Editor.
To better use the online documentation, we'll write an minspect.pmhelp
function that will link us to PyMEL's excellent online documentation.
Tip
As useful as something like pmhelp
can be, this exercise will be even more useful for understanding how PyMEL and Maya work. So even if you don't think you have a use for pmhelp
or if you get stuck with some of the more technical snippets in this chapter, keep going and at least read through how we build the function.
Calling help(pmc.polySphere)
is sort of like calling print pmc.polySphere.__doc__
. The help
function actually uses the interesting pydoc
module, which can do much more, but printing a docstring
is the most common use case. A docstring is the triple-quoted string that describes a function/method/class, as we had for our minspect.info
function. The triple-quoted string gets placed into the function or method's __doc__
attribute.
Invoking the pmhelp
function will bring us to the PyMEL web help page for an object or name, searching for it if we do not get a direct match. We'll be able to use this function from within the mayapy interpreter, the Script Editor in Maya, and even a shelf button. We will use the introspection techniques we've learned to get enough information to search properly.
Let's start by typing up our functions into pseudocode. Pseudocode is prose that we will eventually turn into actual code. It declares exactly what our function needs to do.
# Function converts a python object to a PyMEL help query url. # If the object is a string, # return a query string for a help search. # If the object is a PyMEL object, # return the appropriate url tail. # PyMEL functions, modules, types, instances, # and methods are all valid. # Non-PyMEL objects return None. # Function takes a python object and returns a full help url. # Calls the first function. # If first function returns None, # just use builtin 'help' function. # Otherwise, open a web browser to the help page.
Creating a query string for a PyMEL object
First, open a web browser to the PyMEL help so we can understand the URL schema. For our examples, well use the Maya 2013 English PyMEL help at http://download.autodesk.com/global/docs/maya2013/en_us/PyMel/index.html.
Then, open C:\mayapybook\pylib\minspect.py
and write the following code at the bottom of the file (the comments with numbers are for the explanation following the code sample and do not need to be copied):
def _py_to_helpstr(obj): #(1) return None def test_py_to_helpstr(): #(2) def dotest(obj, ideal): #(3) result = _py_to_helpstr(obj) assert result == ideal, '%s != %s' % (result, ideal) #(4) dotest('maya rocks', 'search.html?q=maya+rocks') #(5)
Let's pick apart the preceding code.
- The leading underscore means this is a protected function. It indicates that we shouldn't call it from outside the module. It is for implementation only.
- This function will contain all of our tests for the different possible objects we can pass in. We can write what we expect, and then assert that our function returns the correct result.
- Defining a function within a function is normal in Python. Since we don't need
dotest
outside of our test function, just define it inside. - The
assert
statement means that if the expression followingthe assert
isFalse
, raise anAssertionError
with the message after the comma. So in this case, we're saying if the result is not equal to ideal, raise an error that tells the user the gotten and ideal values. - We call our inner function. All of the rest of our tests for this function will look very similar.
And in the mayapy interpreter, you can test the code by evaluating the following:
>>> import minspect #(1) >>> reload(minspect) <module 'minspect' from '...\minspect.py'> >>> minspect.test_py_to_helpstr() #(2) Traceback (most recent call last): #(3) AssertionError: ...
Let's walk over how we are running our tests:
- Import and reload
minspect
. - We invoke our test function, which evaluates our assertion.
- Since we didn't actually implement anything, our assertion fails and an
AssertionError
is raised.
The next step is to implement code to pass the test:
def _py_to_helpstr(obj): if isinstance(obj, basestring): return 'search.html?q=%s' % (obj.replace(' ', '+')) return None
Now we can run our test by calling test_py_to_helpstr
again and looking for an error:
>>> reload(minspect).test_py_to_helpstr()
No errors. Congratulations! You have just done Test Driven Development (TDD), which puts you into an elite group of programmers. And we're only in the first chapter!
Tip
Test Driven Development is a technique of developing code where you write your tests first. It is a really an excellent way to program, though not always possible in Maya. See Appendix, Python Best Practices, for more information about TDD.
Creating more tests
Let's go ahead and write more test cases. We will add tests for the pymel.core.nodetypes
module, the pymel.core.nodetypes.Joint
type and instance, the Joint.getTranslation
method, and the pymel.core.animation.joint
function.
def test_py_to_helpstr(): def dotest(obj, ideal): result = _py_to_helpstr(obj) assert result == ideal, '%s != %s' % (result, ideal) dotest('maya rocks', 'search.html?q=maya+rocks') dotest(pmc.nodetypes, 'generated/pymel.core.nodetypes.html' '#module-pymel.core.nodetypes') dotest(pmc.nodetypes.Joint, 'generated/classes/pymel.core.nodetypes/' 'pymel.core.nodetypes.Joint.html' '#pymel.core.nodetypes.Joint') dotest(pmc.nodetypes.Joint(), 'generated/classes/pymel.core.nodetypes/' 'pymel.core.nodetypes.Joint.html' '#pymel.core.nodetypes.Joint') dotest(pmc.nodetypes.Joint().getTranslation, 'generated/classes/pymel.core.nodetypes/' 'pymel.core.nodetypes.Joint.html' '#pymel.core.nodetypes.Joint.getTranslation') dotest(pmc.joint, 'generated/functions/pymel.core.animation/' 'pymel.core.animation.joint.html' '#pymel.core.animation.joint')
I got the ideal values simply by going to the PyMEL online help and copying the appropriate part of the URL. Building code is simpler when your expectations are crystal clear!
The actual implementation of _py_to_helplstr
can be considered simple or complex depending on your experience with Python. It uses lots of double-underscore attributes and demands some understanding of Python's types and inner workings. Memorizing every detail of every line is less important than understanding the basic idea of the code.
We'll use various double underscore attributes such as __class__
and __name__
. They are called either
dunder methods or magic methods, though there's nothing magical about them. Single leading underscore attributes, as we've seen, indicate implementation or protected attributes that callers outside of a module or class should not use. Double leading underscore indicate private attributes, though you can think of them the same as single leading underscore attributes (they cannot be called in a straightforward manner, however). Magic methods are generally not called directly but are for protocols such as the __getitem__
method we saw earlier allowing an object to be indexed like a list or dictionary.
We'll add support for the different types of objects in the order they are listed in the test function until every test passes. We'll start with modules.
Tip
In the _py_to_helpstr
function, we make heavy use of the isinstance
function. This sort of design may seem intuitive, but it is typically a bad practice to check if something is an instance of the class. It is generally better to check if something has functionality or behavior rather than checking what type it is. In this case, though, we do actually want to check if something is an instance. The type of the object is the behavior we are checking for.
Adding support for modules
pymel.core.nodetypes is a module, as are pymel
and pymel.core
. The implementation of _py_to_helpstr
for modules is straightforward. We use the __name__
attribute to identify the module (we will see other uses for __name__
throughout this book).
>>> import pymel.core.nodetypes >>> pymel.core.nodetypes.__name__ 'pymel.core.nodetypes'
To test if something is a module, we can check if the type is the module type
by importing the types
module and checking if an object is an instance of types.ModuleType
.
>>> import types >>> isinstance(pymel.core.nodetypes, types.ModuleType) True
We will continue to use the types
module throughout the rest of this chapter. The code should be mostly self-explanatory so I will only remark on the members we use when their usage is not obvious.
We also need to understand how documentation for modules is laid out in PyMEL's help. URLs to module documentation take the following form:
<base_url>/generated/<module name>.html#module-<module_name>
To support modules, the _py_to_helpstr
function should look like the following (don't forget to add import types
at the top of minspect.py
):
def _py_to_helpstr(obj): if isinstance(obj, basestring): return 'search.html?q=%s' % (obj.replace(' ', '+')) if isinstance(obj, types.ModuleType): return ('generated/%(module)s.html#module-%(module)s' % dict(module=obj.__name__)) return None
If we reload our module and run the test_py_to_helpstr
test function, you will see that the test now passes for pmc.nodetypes
and fails for pmc.nodetypes.Joint
.
Adding support for types
To add support for types such as pymel.core.nodetypes.Joint
, we will use the same technique as we did for modules. We will use the __module__
and __name__
attributes of the Joint
type:
>>> pmc.nodetypes.Joint <class 'pymel.core.nodetypes.Joint'> >>> pmc.nodetypes.Joint.__name__ 'Joint' >>> pmc.nodetypes.Joint.__module__ 'pymel.core.nodetypes'
The PyMEL help format for types is almost the same as the one for modules:
generated/classes/<module>/<module>.<type>.html#<module>.<type>
We'll treat a type as the default (last) case. If we pass in something that is not a type, we can just get the object's type and use that. Let's add support for types into our function:
def _py_to_helpstr(obj): if isinstance(obj, basestring): return 'search.html?q=%s' % (obj.replace(' ', '+')) if isinstance(obj, types.ModuleType): return ('generated/%(module)s.html#module-%(module)s' % dict(module=obj.__name__)) if not isinstance(obj, type): obj = type(obj) return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s' % dict( module=obj.__module__, typename=obj.__name__))
If you reload and invoke the test function, the test should fail for the Joint.getTranslation
method.
Adding support for methods
When we use something like mylist.append(1)
, we can say we are "invoking the append
method with an argument of 1
on the list
instance named 'mylist'
." Methods are things we call on an instance of an object. Things we call on a module are called functions, and we'll cover them after methods.
It's important to define what a method is with precise vocabulary. While speaking with other people, it's not so important if you mix up "function" with "method", but when you're writing code, you must be very clear what you are doing.
The following code creates a Joint
and inspects the type of the instance's getTranslation
method. We can see the method's type is instancemethod
, and it is an instance of types.MethodType
:
>>> joint = pmc.nodetypes.Joint() >>> type(joint.getTranslation) <type 'instancemethod'> >>> isinstance(joint.getTranslation, types.MethodType) True
There are actually several other types of methods in Python. Fortunately, they are largely unimportant for our purposes here. We'll run through them quickly, in case you want to follow up on your own.
>>> class MyClass(object): ... def mymethod(self): ... pass ... @classmethod # (1) ... def myclassmethod(cls): ... pass ... @staticmethod # (2) ... def mystaticmethod(): ... pass >>> MyClass().mymethod # (3) <bound method MyClass.mymethod of <__main__.MyClass object athh... >>> MyClass.mymethod # (4) <unbound method MyClass.mymethod>
Given this class definition, we can observe the following method types:
- Class methods: These use the
@classmethod
decorator and are called with the type of the instance (cls
) instead of the instance itself. We cover decorators in Chapter 4, Leveraging Context Manager and Decorators in Maya. - Static methods: These use the
@staticmethod
decorator and are called with nocls
orself
argument. I always advise against the use of static methods. Just use module functions instead. And in fact, static methods aren't really methods when you inspect their type; they are functions as described in the next section. - Bound methods:
MyClass().mymethod
refers to a bound method. A method is said to be bound when it is associated with an instance. For example, we can saybound = MyClass().mymethod
. When we later invokebound()
, it will always refer to the same instance ofMyClass
. - Unbound method:
MyClass.mymethod
refers to an unbound method. Note we're accessingmymethod
from the class itself, not an instance. You must call unbound methods with an instance. CallingMyClass().mymethod()
andMyClass.mymethod(MyClass())
is roughly equivalent. You rarely use unbound methods directly.
Methods also have three special attributes that link them back to the type and instance they are bound to. The im_self
attribute refers to the instance bound to the method. The im_class
attribute refers to the type the method is declared on. The im_func
refers to the underlying function.
>>> MyClass().mymethod.im_self <__main__.MyClass object at 0x0...> >>> MyClass().mymethod.im_class <class '__main__.MyClass'> >>> MyClass().mymethod.im_func <function mymethod at 0x0...>
The important one for us is the im_class
attribute so we can get the class for this method.
Let's go ahead and add support for methods. The pattern should be very familiar now. The help string format is just the same for types, but with an additional "."
and the name of the method. The new code is highlighted in the following listing:
def _py_to_helpstr(obj): if isinstance(obj, basestring): return 'search.html?q=%s' % (obj.replace(' ', '+')) if isinstance(obj, types.ModuleType): return ('generated/%(module)s.html#module-%(module)s' % dict(module=obj.__name__)) if isinstance(obj, types.MethodType): return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s.%(methname)s' % dict( module=obj.__module__, typename=obj.im_class.__name__, methname=obj.__name__)) if not isinstance(obj, type): obj = type(obj) return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s' % dict( module=obj.__module__, typename=obj.__name__))
Adding support for functions
A function is just like a method, but it isn't part of a class.
>>> def spam(): ... def eggs(): ... pass ... pass
The preceding code contains two functions: spam
and eggs
. Basically, any def
that isn't part of a class definition (the first argument is not self
or cls
in regular methods and class methods described previously) is considered a function. In addition to these earlier simple functions, lambdas
and staticmethods
are also functions, as we see in the following code:
>>> get10 = lambda: 10 >>> type(get10) <type 'function'> >>> class MyClass(object): ... @staticmethod ... def mystaticmethod(): pass >>> type(MyClass.mystaticmethod) <type 'function'> >>> type(MyClass().mystaticmethod) <type 'function'>
Both get10
and MyClass.mystaticmethod
are considered functions by Python and will be handled by the logic in this section.
The PyMEL help URL for functions is straightforward and in fact very similar to types. Implementing functions has no surprises. The code added for handling functions is highlighted in the following listing:
def _py_to_helpstr(obj): if isinstance(obj, basestring): return 'search.html?q=%s' % (obj.replace(' ', '+')) if isinstance(obj, types.ModuleType): return ('generated/%(module)s.html#module-%(module)s' % dict(module=obj.__name__)) if isinstance(obj, types.MethodType): return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s.%(methname)s' % dict( module=obj.__module__, typename=obj.im_class.__name__, methname=obj.__name__)) if isinstance(obj, types.FunctionType): return ('generated/functions/%(module)s/' '%(module)s.%(funcname)s.html' '#%(module)s.%(funcname)s' % dict( module=obj.__module__, funcname=obj.__name__)) if not isinstance(obj, type): obj = type(obj) return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s' % dict( module=obj.__module__, typename=obj.__name__))
If you reload minspect
and run the test_py_to_helpstr
test function, no error should be raised by the assert
. If there is, ensure your test and implementation code is correct.
Adding support for non-PyMEL objects
Right now we only have tests and support for PyMEL objects. We can add support for non-PyMEL objects by returning None
if our argument does not have a module under the pymel
namespace. The check can be a straightforward function using several of the things we've learned about the special attributes of objects. Add the following function somewhere in minspect.py
:
def _is_pymel(obj): try: # (1) module = obj.__module__ # (2) except AttributeError: # (3) try: module = obj.__name__ # (4) except AttributeError: return None # (5) return module.startswith('pymel') # (6)
This logic is based on what we already know about Python objects. All user-defined types and instances have a __module__
attribute, and all modules have a __name__
attribute. Let's walk through the preceding code.
- We use try/except in this function, rather than checking if attributes exist. This is discussed in the following section, Designing with EAFP versus LBYL. If you are not familiar with Python's error handling mechanisms including try/except, we discuss it more in Chapter 4, Leveraging Context Managers and Decorators in Maya.
- If
obj
has a__module__
attribute (for example, if it is a type or function), we will use that as the namespace. - If it does not, an
AttributeError
will be raised, which we catch. - Try to get the
__name__
attribute, assumingobj
is a module. There's a possibilityobj
hasa __name__
and no__module__
but is not a module. We will ignore this possibility (see the section Code is never complete later in this chapter). - If
obj
does not have a__name__
, give up, and returnNone
. - If we do find what we think is a module name, return
True
if it begins with the string"pymel"
andFalse
if not.
We don't need to test this function directly. We can just add another test to our existing test function. The new tests are in highlighted in the following code.
def test_py_to_helpstr(): def dotest(obj, ideal): result = _py_to_helpstr(obj) assert result == ideal, '%s != %s' % (result, ideal) dotest('maya rocks', 'search.html?q=maya+rocks') dotest(pmc.nodetypes, 'generated/pymel.core.nodetypes.html' '#module-pymel.core.nodetypes') dotest(pmc.nodetypes.Joint, 'generated/classes/pymel.core.nodetypes/' 'pymel.core.nodetypes.Joint.html' '#pymel.core.nodetypes.Joint') dotest(pmc.nodetypes.Joint(), 'generated/classes/pymel.core.nodetypes/' 'pymel.core.nodetypes.Joint.html' '#pymel.core.nodetypes.Joint') dotest(pmc.nodetypes.Joint().getTranslation, 'generated/classes/pymel.core.nodetypes/' 'pymel.core.nodetypes.Joint.html' '#pymel.core.nodetypes.Joint.getTranslation') dotest(pmc.joint, 'generated/functions/pymel.core.animation/' 'pymel.core.animation.joint.html' '#pymel.core.animation.joint') dotest(object(), None) dotest(10, None) dotest([], None) dotest(sys, None)
Reload minspect
and run the test function. The first new test should fail. Let's go in and edit our code in minspect.py
to add support for non-PyMEL objects. The changes are highlighted.
def _py_to_helpstr(obj): if isinstance(obj, basestring): return 'search.html?q=%s' % (obj.replace(' ', '+')) if not _is_pymel(obj): return None if isinstance(obj, types.ModuleType): return ('generated/%(module)s.html#module-%(module)s' % dict(module=obj.__name__)) if isinstance(obj, types.MethodType): return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s.%(methname)s' % dict( module=obj.__module__, typename=obj.im_class.__name__, methname=obj.__name__)) if isinstance(obj, types.FunctionType): return ('generated/functions/%(module)s/' '%(module)s.%(funcname)s.html' '#%(module)s.%(funcname)s' % dict( module=obj.__module__, funcname=obj.__name__)) if not isinstance(obj, type): obj = type(obj) return ('generated/classes/%(module)s/' '%(module)s.%(typename)s.html' '#%(module)s.%(typename)s' % dict( module=obj.__module__, typename=obj.__name__))
It's important that the _is_pymel
check comes early so we don't try to generate PyMEL URLs for non-PyMEL objects. We now have a relatively complete function we can be proud of. Reload and run your tests to ensure everything now passes.
Designing with EAFP versus LBYL
In _is_pymel
, we used a try/except statement rather than check if an object has an attribute. This pattern is called Easier to Ask for Forgiveness than Permission (EAFP). In contrast, checking things ahead of time is called Look Before You Leap (LBYL). The former is considered much more Pythonic and generally results in shorter and more robust code. Consider the differences between the following three ways of writing the second try/except inside the _is_pymel
function.
# Version 1 >>> module = None >>> if isinstance(obj, types.ModuleType): ... module = obj.__name__ # Version 2 >>> module = None >>> if hasattr(obj, '__name__'): ... module = obj.__name__ # Version 3 >>> module = getattr(obj, '__name__', None) # Version 4 >>> try: ... module = obj.__name__ ... except AttributeError: ... module = None
Version 1 is thoroughly LBYL and should generally be avoided. We are interested in the __name__
attribute, not whether obj
is a module or not, so we should look for or access the attribute instead of checking the type. Versions 2 and 3 are using LBYL by checking for the __name__
attribute but would be an improvement over version 1 since they are not checking the type. These two versions are about the same, with version 3 being more concise. Version 4 is fully EAFP. Use the style of code that results in the most readable result, but err on the side of EAFP.
There is much more to the debate, and we'll be seeing more instances of EAFP versus LBYL throughout this book.
Code is never complete
Note that the code in this chapter may not be complete. As Donald Knuth said:
Beware of bugs in the above code; I have only proved it correct, not tried it.
There are likely problems with this chapter's code and undoubtedly bugs in the rest of this book. While math may be provably correct, production code that depends on large frameworks (like PyMEL), which themselves rely on complex, stateful systems (like Maya) will never be provably correct. No practical amount of testing can ensure there are no bugs for edge cases.
But there are many types of bugs. In _py_to_helpstr
, a user can pass in a string that may be illegal for a URL. If this were externally released code, we'd want to handle that case, but for personal or in-house code (the vast majority of what you'll write) it is perfectly fine to have "bugs" like this. When the need arises to filter problematic characters, you can add the support.
In the same way, when we find a PyMEL object that isn't compatible with _is_pymel
, or some object that causes an unhandled error, we can edit the code to solve that problem.
The alternative is to try and write bug-free code all the time while predicting all the ways your code can be called. Good luck!
Understanding that we can't write perfect code is one reason why having automated tests is so important. When we find a use case we need to support, we can just go back to our test function, add the test, make sure our test fails, implement the change, make sure our test passes, and then refactor our implementation to make sure our code looks as good as it can. We cover refactoring in Chapter 2, Writing Composable Code.
Opening help in a web browser
Now that all of our tests pass, we can put together our actual pmhelp
function. We need to assemble our generated URL tail with a site, and open it in the web browser. This is actually very simple code because Python comes with so many batteries included. The following code should go into minspect.py
:
import webbrowser # (1) HELP_ROOT_URL = ('http://download.autodesk.com/global/docs/' 'maya2013/en_us/PyMel/')# (2) def pmhelp(obj): # (3) """Gives help for a pymel or python object. If obj is not a PyMEL object, use Python's built-in 'help' function. If obj is a string, open a web browser to a search in the PyMEL help for the string. Otherwise, open a web browser to the page for the object. """ tail = _py_to_helpstr(obj) if tail is None: help(obj) # (4) else: webbrowser.open(HELP_ROOT_URL + tail) # (5)
Let's walk through the preceding code.
- The
webbrowser
module is part of the Python standard library and allows Python to open browser pages. It's awesome! - Define the documentation URL root. Point it to the correct source for your version of Maya and PyMEL.
- Define the
pmhelp
function, and give it a docstring because we are responsible programmers. - If
_py_to_helpstr
returnsNone
, just use the built-inhelp
function. - If
_py_to_helpstr
returns a string, open the URL.
You can now reload your module once more and try it out, for real.
# Will open a browser and search for "joint". >>> minspect.pmhelp('joint') # Will open a browser to the pymel.core.nodetypes module page. >>> minspect.pmhelp(pmc.nodetypes) # Will print out help for integers. >>> minspect.pmhelp(1) Help on int object: class int(object) | int(x[, base]) -> integer ...
Note that doing minspect.pmhelp(minspect.pmhelp)
is totally valid and will show your docstring. This sort of robustness is the hallmark of well-designed code.
We can also hook up pmhelp
to be available through Maya's interface: select an object, hit the pmhelp
shelf button, and a browser page will open to its help page. Just put the following Python "code in a string" into a shelf button (all one line, broken into two here for print):
"import pymel.core as pmc; import minspect; minspect.pmhelp(pmc.selected()[0])"
If you're not sure how to do this, don't worry. We will look more at using Python code in shelf buttons in Chapter 5, Building Graphical User Interfaces for Maya. Also be aware this code will error if nothing is selected, but you'll see how to better handle high-level calls from shelf buttons in Chapter 2, Writing Composable Code.
You may also notice we didn't write any automated tests for pmhelp
like we did for _py_to_helpstr
. Normally I would, especially if this function grows at all. But for now, it's so simple and would take more advanced techniques so we should be pretty confident to leave it alone.