92

I'm having trouble replacing a function from a different module with another function and it's driving me crazy.

Let's say I have a module bar.py that looks like this:

from a_package.baz import do_something_expensive

def a_function():
    print do_something_expensive()

And I have another module that looks like this:

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()

I would expect to get the results:

Something expensive!
Something really cheap.
Something really cheap.

But instead I get this:

Something expensive!
Something expensive!
Something expensive!

What am I doing wrong?

guidoism
  • 7,820
  • 8
  • 41
  • 59
  • The second one cannot work, because your just redefining the meaning of do_something_expensive in your local scope. I do not know however, why the 3rd one is not working... – pajton Mar 03 '10 at 22:18
  • 1
    As Nicholas explains, you're copying a reference and replacing only one of the references. `from module import non_module_member` and module-level monkey-patching are incompatible for this reason, and are both generally best avoided. – bobince Mar 03 '10 at 22:29
  • The preferred package naming scheme is lowercase with no underscores, i.e., `apackage`. – Mike Graham Mar 03 '10 at 22:39
  • 1
    @bobince, module-level mutable state like this is best avoided, with the bad consequences of globals long since recognized. However, `from foo import bar` is just fine and in fact recommended when appropriate. – Mike Graham Mar 03 '10 at 22:45

5 Answers5

111

It may help to think of how Python namespaces work: they're essentially dictionaries. So when you do this:

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

think of it like this:

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

Hopefully you can realize why this doesn't work then :-) Once you import a name into a namespace, the value of the name in the namespace you imported from is irrelevant. You're only modifying the value of do_something_expensive in the local module's namespace, or in a_package.baz's namespace, above. But because bar imports do_something_expensive directly, rather than referencing it from the module namespace, you need to write to its namespace:

import bar
bar.do_something_expensive = lambda: 'Something really cheap.'
Nicholas Riley
  • 43,532
  • 6
  • 101
  • 124
  • What about packages from third party, like [here](https://stackoverflow.com/questions/55721581/python-monkey-patch-fails-why)? – Tengerye Apr 18 '19 at 09:41
22

There's a really elegant decorator for this: Guido van Rossum: Python-Dev list: Monkeypatching Idioms.

There's also the dectools package, which I saw an PyCon 2010, which may be able to be used in this context too, but that might actually go the other way (monkeypatching at the method declarative level... where you're not)

RyanWilcox
  • 13,890
  • 1
  • 36
  • 60
  • 6
    Those decorators do not appear to apply to this case. – Mike Graham Mar 03 '10 at 22:37
  • 1
    @MikeGraham: Guido's email doesn't mention that his example code does also allow for replacing any method, not only adding a new one. So, I think they do apply to this case. – tuomassalo Mar 25 '12 at 14:15
  • @MikeGraham The Guido example does work perfectly for mocking a method statement, I just tried it myself! setattr is just a fancy way of saying '=' ; So 'a = 3' either creates a new variable called 'a' and sets it to three or replaces an existing variable's value with 3 – Chris Huang-Leaver May 22 '15 at 01:00
12

If you want to only patch it for your call and otherwise leave the original code you can use https://docs.python.org/3/library/unittest.mock.html#patch (since 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
  • 16,058
  • 2
  • 88
  • 91
4

In the first snippet, you make bar.do_something_expensive refer to the function object that a_package.baz.do_something_expensive refers to at that moment. To really "monkeypatch" that you would need to change the function itself (you are only changing what names refer to); this is possible, but you do not actually want to do that.

In your attempts to change the behavior of a_function, you have done two things:

  1. In the first attempt, you make do_something_expensive a global name in your module. However, you are calling a_function, which does not look in your module to resolve names, so it still refers to the same function.

  2. In the second example you change what a_package.baz.do_something_expensive refers to, but bar.do_something_expensive is not magically tied to it. That name still refers to the function object it looked up when it was initilized.

The simplest but far-from-ideal approach would be to change bar.py to say

import a_package.baz

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

The right solution is probably one of two things:

  • Redefine a_function to take a function as an argument and call that, rather than trying to sneak in and change what function it is hard coded to refer to, or
  • Store the function to be used in an instance of a class; this is how we do mutable state in Python.

Using globals (this is what changing module-level stuff from other modules is) is a bad thing that leads to unmaintainable, confusing, untestestable, unscalable code the flow of which is difficult to track.

Mike Graham
  • 73,987
  • 14
  • 101
  • 130
0

do_something_expensive in the a_function() function is just a variable within the namespace of the module pointing to a function object. When you redefine the module you are doing it in a different namespace.

vishes_shell
  • 22,409
  • 6
  • 71
  • 81
DavidG
  • 167
  • 1
  • 1
  • 5