¿Cómo un mono parchea una función en Python?

80

Tengo problemas para reemplazar una función de un módulo diferente con otra función y me está volviendo loco.

Digamos que tengo un módulo bar.py que se ve así:

from a_package.baz import do_something_expensive

def a_function():
    print do_something_expensive()

Y tengo otro módulo que se parece a esto:

from bar import a_function
a_function()

from a_package.baz import do_something_expensive
do_something_expensive = lambda: 'Something really cheap.'
a_function()

import a_package.baz
a_package.baz.do_something_expensive = lambda: 'Something really cheap.'
a_function()

Esperaría obtener los resultados:

Something expensive!
Something really cheap.
Something really cheap.

Pero en cambio obtengo esto:

Something expensive!
Something expensive!
Something expensive!

¿Qué estoy haciendo mal?

guidoísmo
fuente
El segundo no puede funcionar, porque simplemente está redefiniendo el significado de do_something_expensive en su ámbito local. Sin embargo, no sé por qué el tercero no funciona ...
pajton
1
Como explica Nicholas, está copiando una referencia y reemplazando solo una de las referencias. from module import non_module_membery el parche de mono a nivel de módulo son incompatibles por esta razón y, en general, es mejor evitarlos.
Bobince
El esquema de nomenclatura de paquete preferido se minúsculas sin subrayado, es decir, apackage.
Mike Graham
1
@bobince, es mejor evitar un estado mutable a nivel de módulo como este, con las malas consecuencias de los globales desde hace mucho tiempo reconocidos. Sin embargo, from foo import barestá bien y de hecho se recomienda cuando sea apropiado.
Mike Graham

Respuestas:

87

Puede ser útil pensar en cómo funcionan los espacios de nombres de Python: son esencialmente diccionarios. Entonces, cuando hagas esto:

from a_package.baz import do_something_expensive
do_something_expensive = lambda: 'Something really cheap.'

piensa en esto, de esta manera:

do_something_expensive = a_package.baz['do_something_expensive']
do_something_expensive = lambda: 'Something really cheap.'

Esperamos que usted puede darse cuenta de por qué esto no funciona, entonces :-) Una vez que se importa un nombre en un espacio de nombres, el valor del nombre del espacio de nombres que ha importado desde es irrelevante. Solo está modificando el valor de do_something_expensive en el espacio de nombres del módulo local, o en el espacio de nombres de a_package.baz, arriba. Pero debido a que la barra importa do_something_expensive directamente, en lugar de hacer referencia a él desde el espacio de nombres del módulo, debe escribir en su espacio de nombres:

import bar
bar.do_something_expensive = lambda: 'Something really cheap.'
Nicholas Riley
fuente
¿Qué pasa con los paquetes de terceros, como aquí ?
Tengerye
21

Hay un decorador realmente elegante para esto: Guido van Rossum: Lista de Python-Dev: Monkeypatching Idioms .

También está el paquete dectools , que vi en un PyCon 2010, que también puede usarse en este contexto, pero que en realidad podría ir al revés (monopatching en el nivel declarativo del método ... donde no está)

RyanWilcox
fuente
4
Estos decoradores no parecen aplicarse a este caso.
Mike Graham
1
@MikeGraham: El correo electrónico de Guido no menciona que su código de ejemplo también permite reemplazar cualquier método, no solo agregar uno nuevo. Entonces, creo que se aplican a este caso.
Tuomassalo
@MikeGraham El ejemplo de Guido funciona perfectamente para burlarse de una declaración de método, ¡lo probé yo mismo! setattr es solo una forma elegante de decir '='; Entonces, 'a = 3' crea una nueva variable llamada 'a' y la establece en tres o reemplaza el valor de una variable existente con 3
Chris Huang-Leaver
6

Si solo desea parchearlo para su llamada y, de lo contrario, dejar el código original, puede usar https://docs.python.org/3/library/unittest.mock.html#patch (desde Python 3.3):

with patch('a_package.baz.do_something_expensive', new=lambda: 'Something really cheap.'):
    print do_something_expensive()
    # prints 'Something really cheap.'

print do_something_expensive()
# prints 'Something expensive!'
Risadinha
fuente
3

En el primer fragmento, hace bar.do_something_expensivereferencia al objeto de función al que se a_package.baz.do_something_expensiverefiere en ese momento. Para realmente "parchear" eso, necesitaría cambiar la función en sí (solo está cambiando a qué se refieren los nombres); esto es posible, pero en realidad no desea hacerlo.

En sus intentos de cambiar el comportamiento de a_function, ha hecho dos cosas:

  1. En el primer intento, convierte do_something_expensive en un nombre global en su módulo. Sin embargo, está llamando a_function, que no busca en su módulo para resolver nombres, por lo que todavía se refiere a la misma función.

  2. En el segundo ejemplo, cambia lo que se a_package.baz.do_something_expensiverefiere, pero bar.do_something_expensiveno está ligado mágicamente a él. Ese nombre todavía se refiere al objeto de función que buscó cuando se inició.

El enfoque más simple pero lejos de ser ideal sería cambiar bar.pypara decir

import a_package.baz

def a_function():
    print a_package.baz.do_something_expensive()

La solución correcta es probablemente una de dos cosas:

  • Redefinir a_functionpara tomar una función como argumento y llamar a eso, en lugar de intentar colarse y cambiar la función a la que está codificada para referirse, o
  • Almacene la función que se utilizará en una instancia de una clase; así es como hacemos el estado mutable en Python.

El uso de globales (esto es lo que significa cambiar las cosas a nivel de módulo de otros módulos) es algo malo que conduce a un código no mantenible, confuso, no comprobable, no escalable, cuyo flujo es difícil de rastrear.

Mike Graham
fuente
0

do_something_expensiveen la a_function()función es solo una variable dentro del espacio de nombres del módulo que apunta a un objeto de función. Cuando redefine el módulo, lo está haciendo en un espacio de nombres diferente.

DavidG
fuente