Requires-Python upper limits
Requires-Python was added to allow older versions of Python to be dropped by packages without breaking installation on older versions of Python. Currently (and for the last 4+ years), pip handles this quite simply; Requires-Python is a free-form SpecifierSet, and it checks to see if the current version of Python is included in the set. If not, it starts going back through a package’s history to find the most recent passing version. This was prompted by IPython dropping Python 2 - 3.3, IIRC, and has accelerated dropping older Python versions like 2.7, 3.5, and now 3.6 and even 3.7 for the data science community (NEP 29). This was designed for and supports lower limits very well.
However, a growing number of packages are starting to use this for upper limits as well. The official specification (PEP 345 and PyPA Core Metadata specification) both state:
This field specifies the Python version(s) that the distribution is guaranteed to be compatible with.
This is extremely problematic; since you cannot “guarantee” compatibility with any future version of Python, every package following the specification seems to be required to add an upper cap. Besides my opinion that this is a horrible practice, the fact that Python itself expects most packages that support the current version without warnings to continue to work for at least the next two versions, and the fact this would seriously impact the already slow support for new Python versions (see the pycln disaster that brought down pybind11, cibuildwheel, and others simply because it had an artificial cap on Python <3.10), this is absolutely not supported by Pip correctly.
Take Numba as an example. Out of any package that I know, this has the hardest cap on Python version possible. It uses bytecode details, which are declared internal (and likely to be changing quite a bit in the optimization focused changes coming in the next few Python versions!). They always have to adapt to new Python versions. So, in version 0.52, they started adding an upper bound to the Requires-Python metadata slot. They also still do the “correct” way to get a nice error message; they add a check and error to setup.py. So, if you pip install numba
on Python 3.10 (a 3.10 compatible 0.55 is about to be released, so you might need pip install numba<0.55
to get this example to work soon), then without explanation pip downloads 0.51, then crashes trying to compile a pinned dependency with no 3.10 wheels (llvmlite). No nice message, and it downloads some seemingly random older version. The extra metadata made this worse; dropping this would have caused the more recent, Python 3.10 compatible llvmlite to be used, would have grabbed numba 0.54, and would have produced the more useful error message.
Edit: I just realized via testing that PDM (and probably Poetry?) use backsolves here too. So using
requires-python = ">=3.8"
on a new PDM project that has a single dependency will get numba 0.51, since it thinks that version supports “all” future Python versions, while 0.54 doesn’t support 3.10+!
Other packages are adopting this too; SciPy is the most recent (and the reason I’m discussing this here). In the full discussion, they are not interested in working with the current implementation in Pip, but want the metadata to be “better” and are following the PEP/description, and adding an old “last” release without the cap that will break when used from any newer Python. This workaround has issues (why download some old version? If there are any dependencies in the future that break, this might not be the first point of failure, etc.) Another workaround based on a manylinux1 idea would be to add Python 3.n+1
wheels as soon as they are allowed (ideally after checking, just in case it is compatible with Python 3.n+1!); these wheels would be empty and would just have a single dependency on some python-not-supported==3.n+1
SDist package that breaks with a message about that version of Python not being supported.
Another major factor (possibly the driving factor already) is that Poetry pushes upper limits on everything, including Python, very hard - it defaults to python = ^3.x
, and the authors even suggest capping at the last tested version. While I’d love for this to change, I’m not hopeful.
Hopefully I’ve shown the current situation is a mess. I’d like to propose three possible solutions that would bring the pip implementation and recommendations in sync, and provide a better path for packages.
I should point out that implementation for all three of these suggestions requires a new feature in packaging’s SpecifierSet: specset.overlap(spec)
. This would compute the analytic overlap of a set with a single specifier - it would return a falsy value if there is no overlap. The True value is unimportant for this use, it could just be a boolean, or it could be the intersection SpecifierSet. I’ve already wanted this for cibuildwheel; asking requires_python.overlap("3.9.*")
would let cibuildwheel know if it needs to build Python 3.9 wheels. For the below ideas, requires_python.overlap(">{current_python_version}")
would allow tools like Pip or setuptools the necessary information to detect upper caps.
I also should point out that Requires-Python
was added to fix broken solves. Using it as an upper cap is trying to break working solves. This is very different, and something that is not supported for other things - there is no Requires-Architecture
or similar. If you are on Python 3.10 and trying to use a package that says <3.10
, then there is no “different” working version. If you allow the solve anyway, then it will either break anyway if the author was correct (though possibly later at runtime), or work correctly if they were just assuming it might not work. Unlike other limits, solvers can’t downgrade the Python version - that’s in the user’s hands.
Finally, locking package managers currently all (Poetry, PDM at least) force the Requires-Python metadata to match the lock file. This means that if you depend on scipy, for example, you will be forced to add scipy’s version cap to your metadata, even if you don’t cap scipy. This is due to the fact the lock file will be “invalid” on the newer Python version. Even though your library might be absolutely fine upgrading. This is really their problem for tying these two settings into one, but still something to keep in mind.
Idea 1: Avoid upper capping
This is the smallest code change, as it really is just standardizing the current way that Pip works. This would change the wording of PEP 345 and the spec to mention that Requires-Python
is not for limiting upgrades to newer Python versions. Something like:
This field specifies the oldest Python version(s) that the distribution is known to be compatible with.
A warning could be added to setuptools, flit, and twine (and suggested for other tools) if an upper cap was detected. This is not what Requires-Python
is for. We already have a system for indicating if a package was tested with a specific Python version - Trove classifiers. Enforcing limits will impede Python upgrades. This is also the nicest solution for lock files with the current implementation of locking package managers, since they currently don’t handle upper bounds correctly for libraries.
Idea 2: Implement upper capping properly
This is a larger code change, but is likely the closest to what users are currently expecting. Pip (and suggested for other solvers) would immediately error out if an upper bound is detected; that is, if all
future versions of Python are also excluded by Requires-Python. I believe Poetry and PDM may already do this.
This still requires changing the PEP 345 and such wording, as adding an upper limit is still a very bad idea for most packages, and would still impair the now yearly Python upgrade process. It also makes it much harder to test alpha/beta/RC’s, which I assume some of the packages doing this may not really be doing. The wording probably should be something like this:
This field specifies the Python version(s) that the distribution is known to be compatible with.
This would indicate that adding an upper cap should only be done if you know the upcoming Python release will break your code. Users will misuse this (as Poetry suggests) and will cap even if they are not sure the next version of Python will break. But they are doing that today too, and this would at least provide the right error message.
Idea 3: Ignore upper capping
This is the only solution that doesn’t require updating the text of PEP 345. The code change is nearly identical to the above solution; Pip (and suggested for other solvers) would ignore the back-search if an upper bound is detected; that is, if all
future versions of Python are also excluded by Requires-Python.
The nice thing (depending on your view) is that this makes the Trove classifiers and Requires-Python identical, so it makes it easier to automatically generate them from Requires-Python. Adding an upper bound would have no downside (as mentioned above, upper caps never fix a solve), so you could use this to actually specify what you are guaranteed to be compatible with without breaking what you might be compatible with.
The few packages that really are incompatible with a future version of Python could do exactly what they are trying to do today, and add an error to setup.py if a newer version is detected. Different build backend could provide ways to error out - in fact, tools like Poetry could use this custom setting to drive the locking instead of the Requires-Python field. Unfortunately, if Poetry, PDM, and others do not follow this ignoring of the field, this could make them harder to use if more packages start adding upper caps.
Option one formalizes the current Pip behavior. Option two gives people what they think they want, even if it is often destructive. Option three minimizes the chances of things failing when they don’t need to, and has some redundancy with Trove classifiers. Personally, I don’t have a strong preference. What do you think? Is there another option I missed?
Closest discussion I could find was https://discuss.python.org/t/use-of-less-than-next-major-version-e-g-4-in-python-requires-setup-py.