Compatibility Code
Most libraries with dependencies will want to support multiple versions of that dependency. But supporting old version is a pain: it requires compatibility code, code that is around solely to get the same output from versions of a library. This post gives some advice on writing compatibility code.
- Don’t write your own version parser
- Centralize all version parsing
- Use consistent version comparisons
- Use Python’s argument unpacking
- Clean up unused compatibility code
1. Don’t write your own version parser
It can be tempting just do something like
if pandas.__version__.split(".")[1] >= "25":
...
But that’s probably going to break, sometimes in unexpected ways. Use either distutils.version.LooseVersion
or packaging.version.parse
which handles all the edge cases.
PANDAS_VERSION = LooseVersion(pandas.__version__)
2. Centralize all version parsing in a _compat.py
file
The first section of compatibility code is typically a version check. It can be tempting to do the version-check inline with the compatibility code
if LooseVersion(pandas.__version__) >= "0.25.0":
return pandas.concat(args, sort=False)
else:
return pandas.concat(args)
Rather than that, I recommend centralizing the version checks in a central _compat.py
file
that defines constants for each library version you need compatibility code for.
# library/_compat.py
import pandas
PANDAS_VERSION = LooseVersion(pandas.__version__)
PANDAS_0240 = PANDAS_VERSION >= "0.24.0
PANDAS_0250 = PANDAS_VERSION >= "0.25.0
This, combined with item 3, will make it easier to clean up your code (see below).
3. Use consistent version comparisons
Notice that I defined constants for each pandas version, PANDAS_0240
,
PANDAS_0250
. Those mean “the installed version of pandas is at least this
version”, since I used the >=
comparison. You could instead define constants
like
PANDAS_LT_0240 = PANDAS_VERSION < "0.24.0"
That works too, just ensure that you’re consistent.
4. Use Python’s argument unpacking
Python’s argument unpacking helps avoid code duplication when the signature of a function changes.
param_grid = {"estimator__alpha": [0.1, 10]}
if SK_022:
kwargs = {}
else:
kwargs = {"iid": False}
gs = sklearn.model_selection.GridSearchCV(clf, param_grid, cv=3, **kwargs)
Using *args
, and **kwargs
to pass through version-dependent arguments lets you
have just a single call to the callable when the only difference is the
arguments passed.
5. Clean up unused compatibility code
Actively developed libraries may eventually drop support for old versions of dependency libraries. At a minimum, this involves removing the old version from your test matrix and bumping your required version in your dependency list. But ideally you would also clean up the now-unused compatibility code. The strategies laid out here intend to make that as easy as possible.
Consider the following.
# library/core.py
import pandas
from ._comapt import PANDAS_0250
def f(args):
...
if PANDAS_0250:
return pandas.concat(args, sort=False)
else:
return pandas.concat(args)
Now suppose it’s the future and we want to drop support for pandas older than 0.25.x
Now all the conditions checking if PANDAS_0250
are automatically true, so we’d
- Delete
PANDAS_0250
from_compat.py
- Remove the import in
core.py
- Remove the
if PANDAS_0250
check, and always have the True part of that condition
# library/core.py
import pandas
def f(args):
...
return pandas.concat(args, sort=False)
I acknowledge that indirection can harm readability. In this case I think it’s warranted for actively maintained projects. Using inline version checks, perhaps with inconsistent comparisons, will make it harder to know when code is now unused. When integrated over the lifetime of the project, I find the strategies laid out here more readable.