compatlib or: what not to do in Python

Just because we can, doesn't mean we should.

Coding Across Python Versions

The asyncio library in Python is not very old, relative to the language: it was introduced in Python 3.4. But even during its short lifetime, asyncio has undergone numerous API changes. One important example is the lineage of the asyncio.run function. Introduced in Python 3.7, asyncio.run is a high-level API to the older asyncio.loop.run_until_complete method.

So if code runs under Python 3.7+ then it can make use of run, otherwise it needs to rely on asyncio.get_event_loop().run_until_complete.

This isn’t a problem if you are writing an application, since you’ll probably run that application under a well-known version of Python – the version you choose.

But if you are writing a library, you might need to support Python <=3.6 and Python3.7+. The code might look something like this:

# code courtesy of @mark-nick-o on mypy issue #12331: https://github.com/python/mypy/issues/12331
if (sys.version_info.major == 3 and sys.version_info.minor >= 7):
    asyncio.run(main())
elif (sys.version_info.major == 3 and sys.version_info.minor <= 6) and (sys.version_info.major == 3 and sys.version_info.minor >= 5):
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(main()))
else:
    print("you currently need to have python 3.5 at least")

That code isn’t too bad, but can we make it better?

compatlib

This is the reason I decided to write compatlib.

Instead of branching if-else statements, compatlib lets you perform method overloading based on the version of the python interpreter:

# my_module.py

import asyncio
from compatlib import compat

@compat.after(3, 4)
def main() -> None:
    asyncio.get_event_loop().run_until_complete(coro())
    return 3.4

@compat.after(3, 7)
def main() -> None:
    asyncio.run(coro())
    return 3.7

This means that invoking my_module.main() on a Python 3.6 interpreter will return 3.4, while running it on Python 3.7 will return 3.7.

To my eyes, the compatlib implementation is much more readable: instead of grokking sys.version_info checks, you can clearly see (major, minor) version tuples and the associated code. But that doesn’t make the compatlib code better than the first example – and it definitely doesn’t make compatlib a good idea.

It wasn’t until I shared compatlib with the world that I realized how dangerous it is.

The conversation that formed around compatlib was far more incisive and interesting than I could have imagined. Folks were discussing the merits and prevalence of librarires supporting multiple Python versions, the quickest and easiest ways to reproduce certain subsets of compatlib features, the parallels with awk's BEGIN blocks, the absence of macros in Python.

And one comment stood out to me – far beyond the rest, because it succinctly captured my thoughts on compatlib: don’t use this.

compatlib makes code more “magical”: by hiding the machinery of “interpreter version checks” behind compat.after, you introduce a lot of unecessary indirection to your code. And, if compatlib breaks (which it very well could, because it hasn’t been thoroughly tested), you now have to debug a library, instead of debugging a few if-else checks.

In fact, it was a recent post from David Bieber in which David shares “magic functions”, sometimes known as implicit arguments. David is very direct:

The short answer is that you should never use magic functions.

It’s amazing to see what the Python community accepts and rejects; what goes into and out of vogue. Hopefully, compatlib stays out of vogue, in favor of simpler, less magical alternatives.