Python: report installed dependency versions, and pick range bounds by kind (ANE-3089) - #1743
Draft
spatten wants to merge 4 commits into
Draft
Python: report installed dependency versions, and pick range bounds by kind (ANE-3089)#1743spatten wants to merge 4 commits into
spatten wants to merge 4 commits into
Conversation
A locator carries one revision, but a version constraint may describe a
whole range, so verConstraintToRevision has to choose. It took the
leftmost candidate, which made the reported version depend on the order
the author happened to write the bounds in:
cryptography>=46.0.3, <60.0.0 -> 46.0.3
cryptography<60.0.0, >=46.0.3 -> 60.0.0
The second is wrong twice over: 60.0.0 is the exclusive upper bound, the
one version the range explicitly forbids, and no such release of
cryptography exists.
Rank candidates by what kind of bound produced them -- exact, then
inclusive lower, inclusive upper, exclusive lower, exclusive upper --
and keep the best rather than the first. Both orderings now report
46.0.3.
This affects every ecosystem that builds a CAnd of bounds, not only
Python: Poetry's comma operator and Elixir's `and` do too.
Ranking is applied to COr as well, where it is arbitrary rather than
principled -- the branches are alternative ranges and nothing says which
is installed -- but it at least makes the result order-independent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When pip is available, the setuptools strategy already runs `pip list`
and `pip show` to discover transitive dependencies and edges. It had the
installed version of every direct dependency in hand and threw it away,
reporting instead a bound taken from the manifest.
Scanning a directory whose requirements.txt says
cryptography<60.0.0, >=46.0.3
against an environment holding cryptography 44.0.1 reported 60.0.0. The
edges to cffi and pycparser came from `pip show cryptography`, so the
real version was right there in the data used to build them.
Substitute the installed version into each direct requirement before
building the graph. Extras and environment markers are preserved --- they
describe the requirement, not the version it resolved to, and the marker
becomes the dependency's tags.
The substitution happens once, up front, because the grapher keys nodes
on Req and Req's Eq instance covers the version: resolving at the
`direct` call but not at the `findParent` lookup would produce two nodes
for one package, one holding the edges and one holding the range.
This also fills in versions for requirements that declare none. A bare
`requests` line previously produced a locator with no revision at all,
even with pip reporting the installed version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ixes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also move the changelog entries under an Unreleased heading, per the PR template. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
A Python dependency declared as a version range was reported at whichever bound the author happened to write first. Given a requirements.txt line of
cryptography<60.0.0, >=46.0.3, the CLI reportedpip+cryptography$60.0.0— the exclusive upper bound, which is the one version the range explicitly forbids, and which is not a release that exists. Writing the same range ascryptography>=46.0.3, <60.0.0reported46.0.3.There are two separate causes, and this PR fixes both.
The first is in
verConstraintToRevision, which turns a version constraint into the single revision a locator can carry. ItsCAndcase took the leftmost candidate that produced a version, and every bound operator was equally willing to answer, so the result depended on source order. It now ranks candidates by what kind of bound produced them — exact, then inclusive lower, inclusive upper, exclusive lower, exclusive upper — and keeps the best rather than the first. This is not Python-specific: Poetry's comma operator and Elixir'sandbuild the same conjunctions.The second is bigger, and is why the reported version was wrong even in the "good" ordering. When
pythonandpipare available, the setuptools strategy already runspip listandpip showto find transitive dependencies and edges. It had the installed version of every direct dependency in hand and discarded it, reporting a bound from the manifest instead. In the scan directory I used to reproduce this, the installed cryptography was44.0.1— neither of the two answers the CLI gave. The edges to cffi and pycparser came frompip show cryptography, so the true version was sitting in the very data used to build them. Direct requirements now take the installed version, with extras and environment markers preserved.While doing that I hoisted
toCanonicalNameout ofStrategy.Python.Poetry.CommonintoStrategy.Python.Util, where both the Poetry and setuptools strategies can reach it, and made it collapse runs of separators the way PEP 503 specifies. Package-name matching in the setuptools strategy was previously a baretoLower, sozope.interfaceandzope-interfacedid not match.Acceptance criteria
When users scan a Python project whose requirements.txt or setup.py declares version ranges:
pythonandpipavailable, dependencies are reported at the version actually installed in the environment.requestsline — now gets a version, where previously it produced a locator with no revision. This is common in real requirements.txt files and is probably the widest-reaching effect of this PR.pythonorpip, which covers--static-only-analysisand container scans, a range falls back to the lowest version it allows rather than an upper bound.Testing plan
Automated coverage is in
test/Srclib/ConverterSpec.hs(new — this function had no tests at all) andtest/Python/ReqTxtSpec.hs. Below is the manual check, which is what actually demonstrates the behavior.Setup. Create a scan directory with these two files:
setup.py:requirements.txt:Install a version of cryptography that differs from what the file declares, so that the manifest and the environment disagree and you can see which one is reported:
1. Upper bound written first, with pip available. Run
fossa analyze --output | jq -r '.sourceUnits[0].Build.Dependencies[].locator'.Expect
pip+cryptography$44.0.1— the installed version. Onmasterthis reportspip+cryptography$60.0.0.2. Lower bound written first. Change the line to
cryptography>=46.0.3, <60.0.0and run the same command.Expect
pip+cryptography$44.0.1again. The reported version must not change when only the bound order changes. Onmasterthis reportspip+cryptography$46.0.3, so the two orderings disagree.3. No version declared at all. Change the line to just
cryptographyand run the same command.Expect
pip+cryptography$44.0.1. Onmasterthis reportspip+cryptography$with no revision.4. Without pip, both bound orders. Run
fossa analyze --output --static-only-analysisagainst each of the two orderings from steps 1 and 2.Expect
pip+cryptography$46.0.3for both — no environment to consult, so the CLI falls back to the lowest version the range allows. Onmasterthe upper-bound-first ordering reports60.0.0here.5. Transitive dependencies still resolve. In any of the runs above, check the full dependency list.
Expect
cffiandpycparserto appear with concrete versions, andpip+cryptography$44.0.1to have animportsedge topip+cffi$.... This confirms the direct dependency and the graph node carrying the edges are the same node, rather than the package appearing twice.Risks
Reporting the installed version overrides an exact pin. If requirements.txt says
requests==2.25.1and the environment has2.30.0, this now reports2.30.0. In a healthy scan — pinned file, clean venv,pip install -ractually run — the two agree and nothing changes. It only differs when the environment has drifted from the manifest, and in that case the environment is what ships. I think this is right, and it is consistent with the CLI already trusting pip for the entire transitive closure, but it is the judgment call in this PR most worth a second opinion.Ranking bounds for
COris arbitrary rather than principled. For a conjunction the branches are complementary bounds on one range, so ranking them means something. For a disjunction they are alternative ranges and nothing in the constraint says which is installed. I applied the same ranking anyway so the result is at least order-independent, and said so in a comment. Open to just leavingCOrleft-biased instead.The PEP 503 change alters Poetry matching.
toCanonicalNamenow collapses separator runs, sofoo__barcanonicalizes tofoo-barinstead offoo--bar. Poetry is the only existing caller. Rare, and the old result looks like a bug, but it is a behavior change outside the ticket's scope.Metrics
Not readily trackable today. The effect worth knowing is how often a reported Python version changes for a given project between CLI versions, which would need a comparison across scans on the server side rather than anything the CLI can emit.
References
Two follow-ups I deliberately left out, both worth their own tickets:
>=46.0.3means that environment was neverpip install -r'd, which makes the transitive graph suspect too. It needs a PEP 440 comparator, and neithersemvernorversionsin our dependency set implements PEP 440's ordering rules for epochs and pre/post/dev releases. Writing one is larger than this whole PR.COrbinds tighter thanCAndin the Poetry and Elixir constraint parsers.makeExprParsertakes its operator table in descending precedence, and both tables list the OR operator first —src/Strategy/Python/Poetry/PyProject.hs:389andsrc/Strategy/Elixir/MixTree.hs:454. In both Poetry and Hex, AND binds tighter, so>=2.0,<3.0 || >=3.2,<4.0should parse as two ranges and currently does not. The current grouping is asserted as correct intest/Python/Poetry/PyProjectSpec.hs:207andtest/Elixir/MixTreeSpec.hs:479, so fixing it means rewriting passing tests.Checklist
docs/.docs/README.msand gave consideration to how discoverable or not my documentation is. — no new pages, only edits to the existingsetuptools.md, which is already linked.Changelog.md. If this PR did not mark a release, I added my changes into an## Unreleasedsection at the top..fossa.ymlorfossa-deps.{json.yml}, I updateddocs/references/files/*.schema.jsonAND I have updated example files used byfossa initcommand. — n/a.docs/references/subcommands/<subcommand>.md. — n/a, no option changes.🤖 Generated with Claude Code