Dla osób przesiadających się z C++ czy Javy to jedna z bardziej szokujących właściwości Pythona (a także Perla i paru innych języków skryptowych): kod programu można zmienić w locie. Można - w szczególności - dopisać albo podmienić metody istniejącej klasy czy funkcje zadanego modułu.
Technika ryzykowna, łatwo prowadząca do powstawania niezrozumiałego kodu i trudnych do interpretacji błędów. Ale zarazem bardzo przydatna, zwłaszcza gdy trzeba zmodyfikować zachowanie jakiejś biblioteki w miejscu, którego jej autor nie przewidział (np. ostatnio przypomniałem ją sobie chcąc zmienić sposób w jaki Mercurial obsługuje hasła HTTP i SMTP). Monkey-patching bardzo często przydaje się też przy testach, pozwalając zamienić prawdziwy kod komunikacyjny czy bazodanowy zaślepkami.
W tym artykule o kilku możliwych sposobach zapisywania tej operacji.
Przykładowy problem
Wszystkie autentyczne przykłady jakie mi przychodzą do głowy są dość
złożone, dlatego posłużę się sztucznym. Wyobraźmy sobie, że
chcemy wpłynąć na działanie funkcji urlopen - dokładniej, chcemy by
nie zwracała ona nagłówków HTTP związanych z cacheowaniem (powiedzmy,
bo jakaś inna biblioteka, której używamy, korzysta z urlopen i
błędnie się zachowuje, gdy napotka te nagłówki). Po krótkim
przeglądzie kodu zauważamy, że nagłówki są zapisywane przy pomocy
funkcji addheader klasy httplib.HTTPMessage.
Chcielibyśmy ją nadmazać ale w naturalny sposób zrobić tego nie możemy,
standardowe biblioteki tworzą explicite obiekty klasy HTTPMessage
i nie ma jak podstawić za nią podklasy.
Bezpośrednio
Prosty bezpośredni zapis może wyglądać tak jak niżej. Po prostu
przepisujemy sobie atrybuty klasy httplib.HTTPMessage, traktując
ją jak normalny obiekt.
from urllib import urlopen
import httplib
httplib.HTTPMessage.skipped_count = 0
orig_addheader = httplib.HTTPMessage.addheader
def my_addheader(self, key, value):
if key in ['cache-control', 'expires', 'x-cache', 'pragma']:
print "skipping", key, value
self.skipped_count = self.skipped_count + 1
else:
orig_addheader(self, key, value)
httplib.HTTPMessage.addheader = my_addheader
httplib.HTTPMessage.skipped_headers = lambda self: self.skipped_count
conn = urlopen("http://www.onet.pl")
msg = conn.info()
print msg.skipped_headers()
Główna wada powyższego: brutalna operacja jaką jest przepisywanie istniejącego kodu jest tu stosunkowo słabo widoczna. Dlatego dość popularnych jest kilka syntaksów pozwalających zapisywać ją bardziej explicite.
Moduł partial
Pierwszym jest moduł partial. Po jego zainstalowaniu:
$ easy_install partial
przepisywanie klas realizujemy dziedzicząc nową klasę (o nieistotnej
nazwie) z partial oraz z modyfikowanej klasy. Może to
wyglądać tak:
from partial import partial, replace
from urllib import urlopen
import httplib
orig_addheader = httplib.HTTPMessage.addheader
class PatchedHttpMessage(partial, httplib.HTTPMessage):
skipped_count = 0
def skipped_headers(self):
return self.skipped_count
@replace
def addheader(self, key, value):
if key in ['cache-control', 'expires', 'x-cache', 'pragma']:
print "skipping", key, value
self.skipped_count = self.skipped_count + 1
else:
orig_addheader(self, key, value)
conn = urlopen("http://www.onet.pl")
msg = conn.info()
print msg.skipped_headers()
Istotna zaleta: partial pilnuje, by nadmazane zostało tylko to,
co nadmazać naprawdę chcemy. Jeśli dodawana metoda nie jest oznaczona
dekoratorem @replace a ma nazwę zgodną z metodą patchowanej klasy,
zostanie zgłoszony błąd.
Dekoratory Guido van Rossuma
Guido van Rossum swego czasu zaproponował dekorator monkeypatch_method oraz metaklasę monkeypatch_class. Szczególnie estetycznie wygląda to pierwsze rozwiązanie:
from urllib import urlopen
import httplib
def monkeypatch_method(cls):
def decorator(func):
setattr(cls, func.__name__, func)
return func
return decorator
orig_addheader = httplib.HTTPMessage.addheader
httplib.HTTPMessage.skipped_count = 0
@monkeypatch_method(httplib.HTTPMessage)
def skipped_headers(self):
return self.skipped_count
@monkeypatch_method(httplib.HTTPMessage)
def addheader(self, key, value):
if key in ['cache-control', 'expires', 'x-cache', 'pragma']:
print "skipping", key, value
self.skipped_count = self.skipped_count + 1
else:
orig_addheader(self, key, value)
conn = urlopen("http://www.onet.pl")
msg = conn.info()
print msg.skipped_headers()
Wadą jest tylko brak syntaksu do dodawania atrybutu.
Zapis z monkeypatch_class jest nieco brzydszy, bo wymusza
deklarowanie w używanym kodzie metaklasy (wspominany wyżej moduł
partial działa podobnie ale ukrywa te szczegóły
pod maską dziedziczenia):
from urllib import urlopen
import httplib
def monkeypatch_class(name, bases, namespace):
assert len(bases) == 1, "Exactly one base class required"
base = bases[0]
for name, value in namespace.iteritems():
if name != "__metaclass__":
setattr(base, name, value)
return base
orig_addheader = httplib.HTTPMessage.addheader
httplib.HTTPMessage.skipped_count = 0
class PatchedHTTPMessage(httplib.HTTPMessage):
__metaclass__ = monkeypatch_class
skipped_count = 0
def skipped_headers(self):
return self.skipped_count
def addheader(self, key, value):
if key in ['cache-control', 'expires', 'x-cache', 'pragma']:
print "skipping", key, value
self.skipped_count = self.skipped_count + 1
else:
orig_addheader(self, key, value)
conn = urlopen("http://www.onet.pl")
msg = conn.info()
print msg.skipped_headers()
Zaleta obu snippetów: są bardzo krótkie, można je po prostu umieścić w własnym kodzie, nie trzeba tworzyć dodatkowych modułów czy dystrybuować dodatkowych bibliotek.
Bardziej dopracowane monkeypatch_method
Nieco dłuższe monkeypatch_method zaproponował Robert Brewer. Tutaj dodatkową
zaletą jest zapamiętywanie nadmazanej metody, do której można się odwołać
(przy pomocy atrybutu _old_oryginalna_nazwa - który jest, na wszelki
wypadek, listą).
from urllib import urlopen
import httplib
def monkeypatch_method(cls):
"""Add the decorated method to the given class; replace as
ed.
If the named method already exists on the given class, it will
be replaced, and a reference to the old method appended to a
at cls._old_{name}. If the "_old_{name}" attribute already
ts
and is not a list, KeyError is raised.
"""
def decorator(func):
fname = func.__name__
old_func = getattr(cls, fname, None)
if old_func is not None:
# Add the old func to a list of old funcs.
old_ref = "_old_%s" % fname
old_funcs = getattr(cls, old_ref, None)
if old_funcs is None:
setattr(cls, old_ref, [])
elif not isinstance(old_funcs, list):
raise KeyError("%s.%s already exists." %
(cls.__name__, old_ref))
getattr(cls, old_ref).append(old_func)
setattr(cls, fname, func)
return func
return decorator
httplib.HTTPMessage.skipped_count = 0
@monkeypatch_method(httplib.HTTPMessage)
def skipped_headers(self):
return self.skipped_count
@monkeypatch_method(httplib.HTTPMessage)
def addheader(self, key, value):
if key in ['cache-control', 'expires', 'x-cache', 'pragma']:
print "skipping", key, value
self.skipped_count = self.skipped_count + 1
else:
print "using", key, value
self._old_addheader[0](self, key, value)
conn = urlopen("http://www.onet.pl")
msg = conn.info()
print msg.skipped_headers()
Modyfikacja modułów
Dekoratory monkeypatch_method równie dobrze jak dla klas działają także dla modułów, pozwalając podmieniać funkcje globalne. Trzeba jedynie jako parametr podać moduł zamiast klasy.
Prosty przykład (zamieniamy funkcję urlopen w module urllib na inną):
import urllib
def monkeypatch_method(cls):
def decorator(func):
setattr(cls, func.__name__, func)
return func
return decorator
@monkeypatch_method(urllib)
def urlopen(self):
raise Exception("Simulating HTTP failure")
conn = urllib.urlopen("http://www.onet.pl")
Posłowie
Kończąc, chciałbym przypomnieć, że wszystkie powyższe rozwiązania należy stosować w ostateczności - a nie jako normalną technikę kodowania. Niemal zawsze wymuszają uzależnianie kodu od wewnętrznych szczegółów implementacyjnych modyfikowanego modułu i są wrażliwe na jego zmiany.
Ale czasem trzeba.