Does anyone have any understanding how poetry is able to produce a lockfile that works cross platform? It seems to violate my understanding and assumptions of how distribution package dependencies work in python.
In theory, it should only be possible to resolve dependencies for a specific interpreter (or environment). Does poetry work because it makes assumptions that tend to be true in general, but with some packages that it can’t support at all? Are there specific instances or examples of packages where poetry simply cannot be used at all?
I’m afraid I don’t. But as I’ve recently been looking at the lockfile standardisation thread, my first instinct is to ask what you even mean by “works”. I thought Poetry lockfiles supported dependencies that are only available in source form, and given that it’s valid to have a package with setup.py containing
it’s difficult to even be clear how you’d lock that at all.
I imagine the answer is that poetry makes some assumptions, and takes a practical attitude over not worrying about weird edge cases. How that translates into cross-platform lockfiles, I don’t know. But I’d be interested in the answer here as well
Surprisingly to me, but poetry produces a lockfile format that captures markers and can be installed on multiple sys_platform and python versions. It also does not appear to only be sdist based. It appears to use both sdist and bdist.
Here is a fragment from the lockfile showing cross sys_platform for python -m poetry add ipython
Markers are a standard feature and they are supplied in the metadata of the package being installed, not by poetry. See here. So I don’t think that’s related to what poetry does.
I’m pretty sure the answer is that they interpret markers, but they ignore the fact sdists are dynamic, so the lockfile isn’t guaranteed to be accurate.
I guess it could grab static metadata directly from wheels. For sdist, all bets are off. However, they may be able to “parse” out the common patterns given that with sdist, quite a lot is static these days…
I believe they assume that sdists always have the same requirements, so you can build locally to get the requirements once and then builds on other platforms will come out the same.
And for environment markers, IIUC the idea is that they try to be conservative and in the lock file record “all the versions that might be relevant, regardless of whether the markers are true or false”. Then when installing on a particular platform, they do another run of their resolution algorithm while taking markers into account, but restricted to just the versions that appear in the lock file.
What I don’t know is how the “all the versions that might be relevant” part is done. I’m not aware of any simple correct algorithm for that, and the brute force approach requires solving an exponential number of NP-complete problems, some of which might fail, but you don’t even know whether the failures are relevant until you try to use the lockfile later. I don’t think they do this, but idk.
From the FAQ. It tries to use the metadata accessible via the PyPI JSON API. The metadata is not always there for all packages, in which case it will download the packages and inspect them. Inspection uses different methods. If there is a PKG-INFO file, it uses pkginfo · PyPI. Otherwise, it falls back to using PEP 517 hooks or even reading setup.cfg.
That info.py does seem to be the place where dependency discovery happens.
From my quick skim, it runs a number of heuristics and fallbacks to sniff the metadata and essentially discover the dependencies from an sdist. Seems very thorough! I guess the key assumption that could lead to incorrect results happens if an sdist does need to be built. As already mentioned by @njs it is assumed that the sdist has the same dependencies on all platforms.
That’s probably not a bad call I think. The number of situations where it would need to build an sdist (without being able to sniff static metadata somewhere else) is probably quite low.
Also that all wheels of a same version have the same dependencies on all platforms. This is not always true,[1] but also quite a reasonable call.
I remember there’s a case several years ago where a quasi-important package has different dependencies across wheels and refused to request to fix it due to the maintainers’ desire to support old pip/setuptools versions that predate environment markers. The situation is likely a lot better these days though. ↩︎
Afaik, that’s true. In some cases when dependencies are determined dynamically in setup.py, poetry has no chance. In simple cases, poetry can even extract the information by parsing setup.py. But of course, that’s not always possible (e.g. in your example). However, most projects seem to define their dependencies in a static way nowadays.
Markers are not evaluated to True/False during locking because environment information is not relevant in order to generate an environment independent lock file.
Basically (simplified) by finding a solution without considering markers. Of course, if there are multiple constraints dependencies, this is not possible. In that case, there are multiple (partial) resolutions with that number of solutions (basically cartesian product of all multiple constraints dependencies). To reduce the number of resolutions, some of these combinations are discarded by checking if the intersection of markers is empty (e.g. the intersection of sys_platform == "linux" and sys_platform != "linux" is empty).
PS: I answered to the best of my knowledge but there are more experienced members than me in the poetry team who may know some things better.
Another issue I’ve run into with Poetry, is that different bdists for the same package+version can have different requirements (e.g. foobar-1.2.3-cp310-win32.whl can depend on numpy==2.0 while foobar-1.2.3-cp310-macosx.whl depends on numpy==2.5). Poetry doesn’t deal with that situation, it assumes dependencies are consistent.
IIRC this is an issue with the opencv-python-headless package.
Yup, Poetry’s resolution logic has a baked-in assumption that all distributions for a package+version will have the exact same dependencies.
IIUC, that’s based on the flawed data representation from PyPI’s JSON API which only exposes dependencies from the first wheel that it sees during a package’s upload.
tbh I think in this case poetry is right and the package is buggy – projects should be using environment markers, not this. Is that something we have written down anywhere? (Is it something we even have consensus on?)
I don’t think we do have a consensus as such, just a sort of communal weary feeling that things would be so much easier if only people wouldn’t do things like this
The packaging user guide would probably be a good place for making a statement like this, but I’m not sure how the PUG authors would feel about maintaining such a document. On the other hand, it’s only really setuptools that is flexible enough to even allow it, so arguably this is something that should be in the setuptools docs, so it’s reaching the right audience (and so that it’s clear that the setuptools maintainers are OK with it).
PEP 643 is the standard that leads towards formalising this type of requirement. But that’s waiting on PyPI accepting metadata 2.2 before we can even start implementing it in tools. And until it’s in wide use, saying that a tool won’t support dependencies that are marked as dynamic isn’t feasible.
Like if your dependency difference is capable of being expressed as an environment marker, then that’s a good thing to do, and you should do it, but afaik wheel tags are more expressive than environment markers are, or at least differently expressive, and in that case having different dependencies serves as a useful escape hatch.
Yeah, wheel tags are sort of weirdly orthogonal to environment markers; you could have completely different requirements for a manylinux_2_17 and manylinux_2_18 wheel, which isn’t something that environment markers can express. (I’m not sure how that would even work; some sort of glibc_version marker?) You could even have different environment markers inside those two wheels, depending on python version or whatever.
So… you’re right it adds some expressivity. I’m not convinced that it’s useful expressivity though :-). By which I mean, maybe there are cases, if someone has them I’d love to see them, but for all I know there might be literally zero packages on PyPI right now that actually benefit from this.
And the cost of supporting this is pretty stark: it basically makes it impossible to support cross-platform lockfiles. Reasoning about foreign environment markers is non-trivial but it’s at least a well-defined problem that you can apply cleverness to. (Apparently poetry has a whole pile of code for logical reasoning about markers, including conversion to conjunctive-normal form, symbolic simplification, all kinds of stuff. tbh it seems like overkill to me, but I do appreciate that with environment markers it’s at least possible.) I don’t even know how to start on reasoning about a mix of ABI tags + markers within those tags. And having to fetch every wheel’s metadata before you can make a lockfile is kind of gratuitous.
And worst case: if you have an sdist, and want to make a portable lock file, you have to build the sdist for every possible target platform before you can know what their requirements are. So saying that different wheels for the same release are free to have arbitrarily different requirements, effectively means that every locking tool needs to have a full cross-compiler setup for every possible target, which is obviously not going to happen. Being able to build the sdist once for the current platform, and then use the resulting metadata to reason about other platforms, is absolutely crucial.