Kurt McKee

lessons learned in production

Archive

Monkeypatching: The basics

Posted 19 July 2020 in monkeypatching, programming, and python

Monkeypatching is a common word among Python programmers. But what is it? What follows is an introduction to monkeypatching, and some of the places where you might encounter it.

Note: the code in this post was tested in Python 3.8.

A definition

"Monkeypatching" refers to modifying how code behaves without also modifying its source code. This may be useful for changing, disabling, or simulating certain behavior.

I have most frequently seen this done during unit testing, but it may be done in production environments too.

A simple example

For example, suppose that a module named slow.py uses time.sleep() extensively.

# slow.py
# -------

import time

def reverse_slowly(text):
    """Reverse the input text."""

    time.sleep(60)
    return ''.join(reversed(text))

Every call to reverse_slowly() takes a full minute, so testing the function will take a long time. However, if we monkeypatch the time module during testing then we can effectively disable the time.sleep() call:

# test.py
# -------

import time

import slow

def test_reverse_slowly():
    """Confirm that reverse_slowly() returns a reversed string."""

    # Keep track of the original time.sleep().
    original_sleep = time.sleep

    # Replace time.sleep() with a do-nothing function.
    time.sleep = lambda seconds: None

    # Confirm functionality.
    assert slow.reverse_slowly('1-2-3') == '3-2-1'

    # Restore the original time.sleep().
    time.sleep = original_sleep

Note: in practice you should use unittest.mock.patch for this situation. I'll provide an example but won't go into detail.

# improved_test.py
# ----------------

import unittest.mock

import slow

@unittest.mock.patch('slow.time.sleep', lambda seconds: None)
def test_reverse_slowly()
    assert slow.reverse_slowly('1-2-3') == '3-2-1'

A feedparser example

feedparser is a venerable library, and some of its initial design decisions have remained unchanged since its inception. Relevantly, it still relies on module-level variables and forces developers to monkeypatch the package to change certain behaviors!

For example, if you don't want feedparser to sanitize the HTML in a feed item, you're supposed to write code like this:

import feedparser

feedparser.SANITIZE_HTML = False
feedparser.parse('https://totally-safe.example/feed')

A production example

I mentioned that you sometimes have to monkeypatch in production.

I've actually done this at work with a library that raises a ValueError if a binary checksum is invalid. I started seeing crashes related to this and discovered that a vendor had introduced an off-by-one error in their binary files. Everything in the file was valid except the checksum!

To fix this, I monkeypatched the library's validate_checksum() function to accept off-by-one errors. This is a more detailed example based on real code.

# library.py
# ----------

def validate_checksum(binary_blob):
    if (sum(binary_blob[:-1]) % 256) ^ binary_blob[-1]:
        raise ValueError('The checksum is invalid!')


# Production code
# ---------------

import library

def validate_checksum_replacement(binary_blob):
    if abs((sum(binary_blob[:-1]) % 256) - binary_blob[-1]) not in {0, 1, 255}:
        raise ValueError('The checksum is *really* invalid!')

library.validate_checksum = validate_checksum_replacement

Conclusion

You will likely need monkeypatching in unit tests but the technique may appear in other contexts, including in your production code.

There are plenty of gotchas that can occur with monkeypatching -- you could introduce thread-safety problems in production code, or you could accidentally, permanently replace something in one unit test that masks problems in subsequent unit tests -- but monkeypatching is a valuable tool, and you need to know how to use it.

Further reading: unittest.mock.patch

☕ Like my work? I accept tips!