Chaining monkey patches in Python: partial legitimization of a venial sin

Monkey patching is a spectacular way to modify third-party code, but a terrible way to make friends (I here of course discount the pervasive problems coders have with socialization in general). Not only does it make issues notoriously difficult to track down, dirtying otherwise clean code with obscurity, but it's arrogant to boot. You've overridden, in perpetuity, any changes made to that third-party code that tread on the territory your monkey patch has conquered. More likely than not, you've rendered upgrades of the patchee untenable without breaking your code or their code or both.

Nonetheless, it's bloody useful, when one exerts a certain degree of control over the process. We use the technique in Zenoss quite a lot, in our ZenPack plugins, which often need to extend the functionality of the main product; since ZenPacks can be created by anyone with Zenoss and a dream, adding hooks into Core manually isn't an option, so ZenPacks have to monkey-patch in their features. It's fine, really. We only feel a little dirty. Less dirty since we started using a decorator!

def monkeypatch(target):
def patcher(func):
setattr(target, func.__name__, func)
return func
return patcher

import MyClass

@monkeypatch(MyClass)
def myfunc():
...

It's pretty trivial, and I'm sure there are myriad identical decorators out there; I think Guido even wrote one at some point. Nonetheless, it makes the process look much less ugly (which is, interestingly, one of the reasons anti-monkey-patching zealots reject such a decorator. They believe that such an ugly practice should look ugly, to deter usage. As if).

Today I was presented with a case where monkey-patching could actually be considered beneficial. I needed to validate manual addition of devices to Zenoss, to make sure they weren't put into a reserved device class. Whatever, no big deal, except it occurred to me that other ZenPacks were likely to override the function as well, which would knock out my validation (since monkey-patching involves replacing existing methods wholesale, he who monkey-patches last wins, and all other patches are discarded). Thus I needed some way to chain monkey patches.

This could, in fact, be considered something of a feature. If a few standard hooks were set up, meant to be monkey-patched an arbitrary number of times, aggregating results, ZenPacks could play nicely with each other—in this case, monkey patching is really the best way to do it.

It only took a few minutes to come up with the decorator:

def monkeypatch_reduce(target, reducefunc):
"""
A decorator factory that chains monkey patches, in the
case where separate patches may modify the same class.
"""
def patcher(func):
try:
_oldfunc = getattr(target, func.__name__)
except AttributeError:
setattr(target, func.__name__, func)
return func
def inner(*args, **kwargs):
return reducefunc(
_oldfunc(*args, **kwargs),
func(*args, **kwargs))
setattr(target, func.__name__, inner)
return inner
return patcher

With this decorator, you can monkey patch a class over and over, using any reduction function you want, and get the reduced result. For example, my validation function would use a reduction function that returns True if all criteria validate, and False otherwise:

from Products.ZenModel import Device

@monkeypatch_reduce(Device, reducefunc=lambda x,y:x and y)
def validate_device(dev):
return dev.fulfills_some_criteria()

@monkeypatch_reduce(Device, reducefunc=lambda x,y:x and y)
def validate_device(dev):
return dev.fulfills_other_criteria()

Any ZenPack can add their criteria to the validation process by monkey patching with this decorator, without worrying at all about anybody else's monkey patches.

One could also create a hook that would be monkey-patched to provide a kind of registry:

ZenPack.py:
-----------
def zenpack_list(self):
return []

A ZenPack:
----------
from Products.ZenModel import ZenPack
@monkeypatch_reduce(ZenPack, operator.add)
def zenpack_list(self):
return ["A Pack"]

A different ZenPack:
--------------------
from Products.ZenModel import ZenPack
@monkeypatch_reduce(ZenPack, operator.add)
def zenpack_list(self):
return ["A Different Pack"]


See? Each acts as if it's providing the only result, and the patch chain reduces them into a list:

>>> ZenPack.zenpack_list()
["A Pack", "A Different Pack"]

I dunno, maybe it's crazy. But I think it's got legs. I'm going to play around with it a bit more.

0 comments

Post a Comment