Exquisite Python hack — Smalltalk-style class extensions

I’ve discovered (or rediscovered) an exquisite hack — a way to add or (in this case) replace methods of an existing but out-of-scope module, and then put them back again.

Motivation

The minimal ZeeUnit is intended to discover the minimal Zeetix kernel. It therefore should not depend on any clazzes that are outside the Kernel assembly.

In order to reliably restore the underlying database (from which the Kernel clazzes are loaded), it uses code within the existing kernel that (incorrectly) requires clazzes from two non-kernel assemblies — Admin and Command.

I needed a way to use the existing kernel code, while not adding these two big warts to ZeeUnit. I therefore cloned the required clazzes into KernelTest.CoreTest, and made minor edits so that they work.

Unfortunately, two kernel clazzes — ItClazz and ItRecordClazz — contain methods that expect to find these missing clazzes in their original location.

I therefore needed a way to temporarily replace the offending methods so that the new test code works properly.

Solution

I borrowed a paradigm from Smalltalk — Smalltalk-style class extensions. A class extension, in Smalltalk, is a group of methods that can be atomically added or removed from an existing Smalltalk class. This behavior is simulated in Ruby by the Ruby “module” mechanism.

It turns out that a Python class uses a dictionary-style mechanism, similar to Smalltalk, for method dispatch. Python supports adding and removing methods from classes, at run time, by assignment operations on the class. The trickiest part is determining the specific Python class that is associated with a given Zeetix clazz (or Metaclazz).

I used the setUp and tearDown hooks in TestCase to install and then remove the special methods, leaving the Kernel unaffected except while running the test(s) in question.

Result

The result is that the special behavior added by the new test case is available to every existing instance of every Zeetix object, without modification. This proved extraordinarily valuable in this case, because the desired behavior is used across the clazz and metaclazz hierarchy. The resulting code works like a charm.

Code

Here is the code fragment, from KernelTest.CoreTest.PrepareForDebugTest, that does the magic:

class PrepareForDebugTest(TestCase):
    def frobExistingMethods(self):
        itClazzClass = sys.modules.get('Zeetix.Kernel.Core').__dict__.get('ItClazz').__dict__.get('ItClazz')
        self._oldNoOpMethod = itClazzClass.noOpCommandClazz
        itClazzClass.noOpCommandClazz = self.noOpCommandClazz

        # Install my commandListClazz in ItRecordClazz
        itRecordClazzClass = sys.modules.get('Zeetix.Kernel.Core').__dict__.get('ItRecord').__dict__.get('ItRecordClazz')
        self._oldCommandListClazzMethod = itRecordClazzClass.commandListClazz
        itRecordClazzClass.commandListClazz = self.commandListClazz

        # Install my deleteCommandClazz in ItRecordClazz
        self._oldDeleteCommandClazzMethod = itRecordClazzClass.deleteCommandClazz
        itRecordClazzClass.deleteCommandClazz = self.deleteCommandClazz

    def unFrobExistingMethods(self):
        """Put the old methods back the way they were."""
        itClazzClass = sys.modules.get('Zeetix.Kernel.Core').__dict__.get('ItClazz').__dict__.get('ItClazz')
        itRecordClazzClass = sys.modules.get('Zeetix.Kernel.Core').__dict__.get('ItRecord').__dict__.get('ItRecordClazz')

        itClazzClass.noOpCommandClazz = self._oldNoOpMethod
        itRecordClazzClass.deleteCommandClazz = self._oldDeleteCommandClazzMethod
        itRecordClazzClass.commandListClazz = self._oldCommandListClazzMethod

    def doSetUp(self):
        #...
        self.frobExistingMethods()

    def doTearDown(self):
        self.unFrobExistingMethods()

    def noOpCommandClazz(self):
        answer = self.them().clazzAt_('KernelTest.CoreTest.NoOpCommand')
        return answer

    def deleteCommandClazz(self):
        answer = self.them().clazzAt_('KernelTest.CoreTest.DeleteCommand')
        return answer

    def commandListClazz(self):
        answer = self.them().clazzAt_('KernelTest.CoreTest.CommandList')
        return answer

The doSetUp method (I’m using the Template Method pattern here, the “do” prefix cues the developer that an implementation, if provided, will be invoked) invokes the hack (“frobExistingMethods”), and the doTearDown method puts things back.

I use instance variables of the test case to store the old methods, so that they can be restored when the test case exits.

Uses

This approach, when suitably cleaned up, enables assemblies and subassemblies to contain clazz extensions for clazzes defined elsewhere. This, in turn, enables a significant enhancement of developer productivity. For example, conversion methods from one clazz to another become far more straightforward. An assembly that, for example, provides a special number type (“MySpecialNumberType”) can add its own “asMySpecialNumberType” to existing clazzes, allowing simple conversions without violating encapsulation or dependency constraints.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s


%d bloggers like this: