Affe, der eine Klasse in einem anderen Modul in Python patcht

76

Ich arbeite mit einem Modul, das von jemand anderem geschrieben wurde. Ich möchte die __init__Methode einer im Modul definierten Klasse mit einem Affen-Patch versehen . Die Beispiele, die ich gefunden habe, um zu zeigen, wie das geht, haben alle angenommen, dass ich die Klasse selbst aufrufen würde (z. B. Python-Klasse mit Affen-Patch ). Dies ist jedoch nicht der Fall. In meinem Fall wird die Klasse innerhalb einer Funktion in einem anderen Modul initialisiert. Siehe das (stark vereinfachte) Beispiel unten:

Thirdpartymodule_a.py

class SomeClass(object):
    def __init__(self):
        self.a = 42
    def show(self):
        print self.a

Thirdpartymodule_b.py

import thirdpartymodule_a
def dosomething():
    sc = thirdpartymodule_a.SomeClass()
    sc.show()

mymodule.py

import thirdpartymodule_b
thirdpartymodule_b.dosomething()

Gibt es eine Möglichkeit, die __init__Methode SomeClassso zu ändern , dass beim dosomethingAufruf von mymodule.py beispielsweise 43 statt 42 gedruckt wird? Idealerweise könnte ich die vorhandene Methode umbrechen.

Ich kann die .py-Dateien für das dritte Modul * nicht ändern, da andere Skripte von der vorhandenen Funktionalität abhängen. Ich möchte lieber keine eigene Kopie des Moduls erstellen, da die Änderung, die ich vornehmen muss, sehr einfach ist.

Bearbeiten 2013-10-24

Ich habe im obigen Beispiel ein kleines, aber wichtiges Detail übersehen. SomeClasswird thirdpartymodule_bwie folgt importiert : from thirdpartymodule_a import SomeClass.

Um den von FJ vorgeschlagenen Patch zu erstellen, muss ich die Kopie ersetzen thirdpartymodule_bund nicht thirdpartymodule_a. zB thirdpartymodule_b.SomeClass.__init__ = new_init.

Snorfalorpagus
quelle
Ich verstehe nicht, warum es einen Unterschied machen würde, von wo aus Sie die Klasse anrufen.
Daniel Roseman
1
Dateinamen sollten sein thirdpartymodule_a.py, thirdpartymodule_b.py.
Falsetru

Antworten:

83

Folgendes sollte funktionieren:

import thirdpartymodule_a
import thirdpartymodule_b

def new_init(self):
    self.a = 43

thirdpartymodule_a.SomeClass.__init__ = new_init

thirdpartymodule_b.dosomething()

Wenn Sie möchten, dass der neue Init den alten Init aufruft, ersetzen Sie die new_init()Definition durch die folgende:

old_init = thirdpartymodule_a.SomeClass.__init__
def new_init(self, *k, **kw):
    old_init(self, *k, **kw)
    self.a = 43
Andrew Clark
quelle
3
Vielleicht sollten Sie einen Anruf beim Alten einschließen __init__.
Benutzer
1
Es scheint SomeClassviel eleganter zu sein, von der Klasse zu erben und sie zu ersetzen, als mit den __init__Funktionen selbst herumzuspielen .
Jonathon Reinhart
2
@ JonathonReinhart Sie haben wahrscheinlich Recht, aber ich glaube nicht, dass das OP in seinem eigenen Code wirklich 42 durch 43 ersetzen möchte. Er fragte speziell nach Affen-Patching
arainone
48

Verwenden mockBibliothek.

import thirdpartymodule_a
import thirdpartymodule_b
import mock

def new_init(self):
    self.a = 43

with mock.patch.object(thirdpartymodule_a.SomeClass, '__init__', new_init):
    thirdpartymodule_b.dosomething() # -> print 43
thirdpartymodule_b.dosomething() # -> print 42

oder

import thirdpartymodule_b
import mock

def new_init(self):
    self.a = 43

with mock.patch('thirdpartymodule_a.SomeClass.__init__', new_init):
    thirdpartymodule_b.dosomething()
thirdpartymodule_b.dosomething()
falsetru
quelle
7
Dies ist der einzige Weg, der tatsächlich richtig funktioniert. Dies ist im Grunde ein Affen-Patch, während Sie Ihren Anruf tätigen, etwas tun und dann den Affen-Patch rückgängig machen. Auf diese Weise erhalten andere Module, die es aufrufen, immer noch das ursprüngliche Verhalten. Nur Sie erhalten das geänderte Verhalten. (und danke für den Hinweis auf Mock!)
Corley Brigman
@CorleyBrigman Dies gilt nur für andere Module innerhalb desselben Prozesses. Für mich klingen "andere Skripte" so, als wären sie separate Python-Prozesse, die von einem naiven Affen-Patch nicht betroffen wären.
Andrew Clark
3

Ein weiterer möglicher Ansatz, der dem von Andrew Clark sehr ähnlich ist, ist die Verwendung der Wrapt- Bibliothek. Unter anderem nützlichen Dingen, bietet diese Bibliothek wrap_function_wrapperund patch_function_wrapperHelfer. Sie können wie folgt verwendet werden:

import wrapt
import thirdpartymodule_a
import thirdpartymodule_b

@wrapt.patch_function_wrapper(thirdpartymodule_a.SomeClass, '__init__')
def new_init(wrapped, instance, args, kwargs):
    # here, wrapped is the original __init__,
    # instance is `self` instance (it is not true for classmethods though),
    # args and kwargs are tuple and dict respectively.

    # first call original init
    wrapped(*args, **kwargs)  # note it is already bound to the instance
    # and now do our changes
    instance.a = 43

thirdpartymodule_b.do_something()

Oder manchmal möchten Sie vielleicht verwenden, wrap_function_wrapperwas kein Dekorateur ist, aber othrewise funktioniert genauso:

def new_init(wrapped, instance, args, kwargs):
    pass  # ...

wrapt.wrap_function_wrapper(thirdpartymodule_a.SomeClass, '__init__', new_init)
MarSoft
quelle
2

Schmutzig, aber es funktioniert:

class SomeClass2(object):
    def __init__(self):
        self.a = 43
    def show(self):
        print self.a

import thirdpartymodule_b

# Monkey patch the class
thirdpartymodule_b.thirdpartymodule_a.SomeClass = SomeClass2

thirdpartymodule_b.dosomething()
# output 43
lucasg
quelle
2
Was ist, wenn ich möchte, dass meine neue Klassendefinition die alte erweitert (über Vererbung)?
Yucer
1
warum nicht erben von SomeClass?
Arnie97
1

Eine nur etwas weniger hackige Version verwendet globale Variablen als Parameter:

sentinel = False

class SomeClass(object):
    def __init__(self):
        global sentinel
        if sentinel:
            <do my custom code>
        else:
            # Original code
            self.a = 42
    def show(self):
        print self.a

Wenn Sentinel falsch ist, verhält es sich genauso wie zuvor. Wenn es wahr ist, dann bekommen Sie Ihr neues Verhalten. In Ihrem Code würden Sie Folgendes tun:

import thirdpartymodule_b

thirdpartymodule_b.sentinel = True    
thirdpartymodule.dosomething()
thirdpartymodule_b.sentinel = False

Natürlich ist es ziemlich trivial, dies zu einer korrekten Korrektur zu machen, ohne den vorhandenen Code zu beeinträchtigen. Aber Sie müssen das andere Modul leicht ändern:

import thirdpartymodule_a
def dosomething(sentinel = False):
    sc = thirdpartymodule_a.SomeClass(sentinel)
    sc.show()

und an init übergeben:

class SomeClass(object):
    def __init__(self, sentinel=False):
        if sentinel:
            <do my custom code>
        else:
            # Original code
            self.a = 42
    def show(self):
        print self.a

Vorhandener Code funktioniert weiterhin - sie rufen ihn ohne Argumente auf, wodurch der standardmäßige falsche Wert beibehalten wird und das alte Verhalten beibehalten wird. Mit Ihrem Code können Sie jetzt dem gesamten Stapel mitteilen, dass neues Verhalten verfügbar ist.

Corley Brigman
quelle
1

Hier ist ein Beispiel kam ich mit zu monkeypatch Popenmit pytest.

Importieren Sie das Modul:

# must be at module level in order to affect the test function context
from some_module import helpers

Ein MockBytesObjekt:

class MockBytes(object):

    all_read = []
    all_write = []
    all_close = []

    def read(self, *args, **kwargs):
        # print('read', args, kwargs, dir(self))
        self.all_read.append((self, args, kwargs))

    def write(self, *args, **kwargs):
        # print('wrote', args, kwargs)
        self.all_write.append((self, args, kwargs))

    def close(self, *args, **kwargs):
        # print('closed', self, args, kwargs)
        self.all_close.append((self, args, kwargs))

    def get_all_mock_bytes(self):
        return self.all_read, self.all_write, self.all_close

Eine MockPopenFabrik zum Sammeln der Scheinpopens:

def mock_popen_factory():
    all_popens = []

    class MockPopen(object):

        def __init__(self, args, stdout=None, stdin=None, stderr=None):
            all_popens.append(self)
            self.args = args
            self.byte_collection = MockBytes()
            self.stdin = self.byte_collection
            self.stdout = self.byte_collection
            self.stderr = self.byte_collection
            pass

    return MockPopen, all_popens

Und ein Beispieltest:

def test_copy_file_to_docker():
    MockPopen, all_opens = mock_popen_factory()
    helpers.Popen = MockPopen # replace builtin Popen with the MockPopen
    result = copy_file_to_docker('asdf', 'asdf')
    collected_popen = all_popens.pop()
    mock_read, mock_write, mock_close = collected_popen.byte_collection.get_all_mock_bytes()
    assert mock_read
    assert result.args == ['docker', 'cp', 'asdf', 'some_container:asdf']

Dies ist das gleiche Beispiel, aber wenn Sie pytest.fixturees verwenden, wird der integrierte PopenKlassenimport überschrieben in helpers:

@pytest.fixture
def all_popens(monkeypatch): # monkeypatch is magically injected

    all_popens = []

    class MockPopen(object):
        def __init__(self, args, stdout=None, stdin=None, stderr=None):
            all_popens.append(self)
            self.args = args
            self.byte_collection = MockBytes()
            self.stdin = self.byte_collection
            self.stdout = self.byte_collection
            self.stderr = self.byte_collection
            pass
    monkeypatch.setattr(helpers, 'Popen', MockPopen)

    return all_popens


def test_copy_file_to_docker(all_popens):    
    result = copy_file_to_docker('asdf', 'asdf')
    collected_popen = all_popens.pop()
    mock_read, mock_write, mock_close = collected_popen.byte_collection.get_all_mock_bytes()
    assert mock_read
    assert result.args == ['docker', 'cp', 'asdf', 'fastload_cont:asdf']
jmunsch
quelle