Patch the inner source of python functions at runtime.
A quick example, making a function that returns 1 instead return 9001:
>>> defsample():
... return1
...
>>> patchy.patch(
... sample,
... """\
... @@-1,2+1,2@@
... defsample():
... -return1
... +return9001
... """,
... )
>>> sample()
9001Patchy works by replacing the code attribute of the function, leaving the function object itself the same. It's thus more versatile than monkey patching, since if the function has been imported in multiple places they'll also call the new behaviour.
Hacking on a Django project? Check out my book Boost Your Django DX which covers many ways to improve your development experience.
Use pip:
python -m pip install patchyPython 3.10 to 3.15 supported.
If you’re monkey-patching an external library to add or fix some functionality, you will probably forget to check the monkey patch when you upgrade it. By using a patch against its source code, you can specify some context that you expect to remain the same in the function that will be checked before the source is applied.
I found this with some small but important patches to Django for a project. Since it takes a lot of energy to maintain a fork, writing monkey patches was the chosen quick solution, but then writing actual patches would be better.
The patches are applied in-memory by unipatch, a pure-Python implementation of unified diff patching, compatible with the behaviour of the GNU patch commandline utility.
There are of course a lot of reasons against:
- If you have a patch file, why not just fork the library and apply it?
- At least with monkey-patching you know what end up with, rather than having the changes being done at runtime to source that may have changed.
All are valid arguments. However once in a while this might be the right solution.
The standard library function inspect.getsource() is used to retrieve the source code of the function, the patch is applied in-memory with unipatch, the code is recompiled, and the function’s code object is replaced the new one.
Because nothing tends to poke around at code objects apart from dodgy hacks like this, you don’t need to worry about chasing any references that may exist to the function, unlike mock.patch.
A little special treatment is given to instancemethod, classmethod, and staticmethod objects to make sure the underlying function is what gets patched and that you don't have to worry about the details.
Apply the patch patch_text to the source of function func. func may
be either a function, or a string providing the dotted path to import a
function.
If the patch is invalid, for example the context lines don’t match,
ValueError will be raised, with a message that includes all the output from
the patch utility.
Note that patch_text will be textwrap.dedent()’ed, but leading
whitespace will not be removed. Therefore the correct way to include the patch
is with a triple-quoted string with a backslash - """\ - which starts the
string and avoids including the first newline. A final newline is not required
and will be automatically added if not present.
Example:
importpatchydefsample():
return1patchy.patch(
sample,
"""\ @@ -2,2 +2,2 @@ - return 1 + return 2""",
)
print(sample()) # prints 2An alias for patch, so you can meme it up by calling
patchy.mc_patchface().
Unapply the patch patch_text from the source of function func. This is
the reverse of patch()ing it, and calls patch --reverse.
The same error and formatting rules apply as in patch().
Example:
importpatchydefsample():
return2patchy.unpatch(
sample,
"""\ @@ -2,2 +2,2 @@ - return 1 + return 2""",
)
print(sample()) # prints 1Takes the same arguments as patch. Usable as a context manager or function
decorator to wrap code with a call to patch before and unpatch after.
Context manager example:
defsample():
return1234patch_text="""\ @@ -1,2 +1,2 @@ def sample(): - return 1234 + return 5678 """withpatchy.temp_patch(sample, patch_text):
print(sample()) # prints 5678Decorator example, using the same sample and patch_text:
@patchy.temp_patch(sample, patch_text)defmy_func():
returnsample() ==5678print(my_func()) # prints TrueCheck that function or dotted path to function func has an AST matching
expected_source, then replace its inner code object with source compiled
from new_source. If the AST check fails, ValueError will be raised with
current/expected source code in the message. In the author's opinion it's
preferable to call patch() so your call makes it clear to see what is being
changed about func, but using replace() is simpler as you don't have to
make a patch and there is no subprocess call to the patch utility.
Note both expected_source and new_source will be
textwrap.dedent()’ed, so the best way to include their source is with a
triple quoted string with a backslash escape on the first line, as per the
example below.
If you want, you can pass expected_source=None to avoid the guard against
your target changing, but this is highly unrecommended as it means if the
original function changes, the call to replace() will continue to silently
succeed.
Example:
importpatchydefsample():
return1patchy.replace(
sample,
"""\ def sample(): return 1 """,
"""\ def sample(): return 42 """,
)
print(sample()) # prints 42Save the source of the function of interest (and nothing else) in a
.pyfile, e.g.before.py:deffoo(): print("Change me")
Make sure you dedent it so there is no whitespace before the
def, i.e.dis the first character in the file. For example if you wanted to patch thebar()method below:classFoo: defbar(self, x): returnx*2
...you would put just the method in a file like so:
defbar(self, x): returnx*2
However we'll continue with the first example
before.pysince it's simpler.Copy that
.pyfile, to e.g.after.py, and make the changes you want, such as:deffoo(): print("Changed")
Run
diff, e.g.diff -u before.py after.py. You will get output like:diff --git a/Users/chainz/tmp/before.py b/Users/chainz/tmp/after.py index e6b32c6..31fe8d9 100644 --- a/Users/chainz/tmp/before.py+++ b/Users/chainz/tmp/after.py@@ -1,2 +1,2 @@ def foo(): - print("Change me")+ print("Changed")
The filenames are not necessary for
patchyto work. Take only from the first@@line onwards into the multiline string you pass topatchy.patch():patchy.patch( foo, """\ @@ -1,2 +1,2 @@ def foo(): - print("Change me") + print("Changed") """, )
