2007-12-16

What is the best way to test both a Python and C version of a module?

If I had my way, everything in the stdlib that didn't need to be an extension module would be implemented in Python. That would make maintenance so much easier. Plus it would make things better for the various alternative implementations of Python.

But this kind of restriction ain't about to happen (especially with Raymond Hettinger around as he is a speed freak =). In some cases we actually have two implementations of a module; one in Python, one in C (see heapq and bisect for examples). This usually occurs because the module was originally written in Python but someone really wanted a version in C for performance reasons. This actually makes sense to me; write in Python and if you actually find in real life that you need faster, re-implement in C while keeping the Python version around. Chances are if there is a bug it will be in the C version (see the compatibility issues with StringIO/cStringIO and pickle/cPickle), so maintenance might suck by having two versions, but usually the person who goes far enough to implement a C part keeps it minimal so it isn't too bad.

But in these cases where a module in the stdlib has a Python version that imports a C version underneath the covers when available, both versions of the code need to be tested. But how can you do that in a simple fashion? I have an idea ...

Let's look at the basic idiom in play here. Typically there is a Python module that contains all the code. Then, somewhere towards the bottom, the C version is import with a ``from .. import ..`` statement which then overwrites the Python functions and such with the C versions. I am going to assume here that no one uses the delegate pattern and thus only imports the C module and then references off the module.

OK, let's assume you have run your unit tests once with whatever the module gives you by default on your system. Assuming that is the C version, we now want only the Python code to be tested. First, we need to trigger the module we are testing to be reloaded without the C code. That happens by storing the C version locally and then setting a value in sys.modules for the C module that will fail on a ``from .. import ..`` call. Technically this should not be 'None' since that has special meaning when it comes to packages (leads to a redirect in the import); I am partial to 42. The storing of the C module is important as extension modules are typically not designed to be reloaded, so you should assume they can't be reloaded.

With a guaranteed import failure of the Python code, we can now delete the Python module from sys.modules and execute our import again. The Python code should not end up importing the C code since the import fails. If you are doing this in a function, you need to make sure the names that you re-import are specified as global to get them rebound in the global namespace or else your re-import will just be local and your test module will still have a reference to the first import that uses the C code (and it will still run fine thanks to reference counting).

Finally, once the tests are run again you should delete the module you are testing one more time from sys.modules so you can use the C version again.

Here is some example code::


from monty.python import spam

class TestClass: pass

def test_main():
run_tests(TestClass)
from sys import modules
if '_spam' in modules:
global spam
c = modules['monty.python._spam']
modules['monty.python._spam'] = 42
del modules['monty.python.spam']
from monty.python import spam
run_tests(TestClass)
modules['monty.python._spam'] = c
del modules['monty.python.spam']


There are some issues with this approach, though. First, assumptions are being made that the module is designed to be reloaded. If it does a bunch of global caching in the module then there will be discrepancies between code that imported the module previous and code that imports the module from this point forward. Second, there is a lot of brittle code there that should not be messed with. One could write a context manager and that would give you::



from monty.python import spam

class TestClass: pass

def test_main():
run_tests(TestClass)
with hide_c('monty.python.spam', 'monty.python._spam'):
global spam
from monty.python import spam
run_tests(TestClass)


Better, but not perfect. It would be nice to ditch the explicit import. You could have run_tests() take the same arguments as hide_c(). With that you could introspect on the test classes to find out which test modules are being run. Then you could inspect their global namespace to find instances of the module being tested and directly substitute in any re-imported module. But at that point you are implementing an extended reload() that does a search for references to the old module and does a direct substitution.

Probably the biggest issue with either approach, though, is subclasses. If you wrote a mock object that subclassed the thing that it was stubbing out for instance/subclass checks, you would still be using the C code version of the module. You would have to explicitly clobber the mock objects and the module that produced them to make sure you didn't have any more issues.

Unfortunately there is no fool-proof solution. This might come down to a good-enough solution in the end.