-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Skip yanked releases unless specified #10625
Conversation
Relevant section of the standard: https://www.python.org/dev/peps/pep-0592/#installers |
Is there anything else I have to do on my side to proceed with this PR? |
A test to verify this works would be great! This change looks about right, other than that! |
Also, please fix the linter error caught by pre-commit |
I fixed the linter error and added a test for the expected error behavior. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a "Fix" keyword to the description so the linked issues can be closed automatically.
Could you also look into creating a test case for #8262? That needs more kinds of releases so we'll need to use |
I am not sure this PR fixes #8262, at least not with the expected behavior they proposed in the issue. I implemented a test with <a data-yanked="test reason message" href="../../../packages/simple-1.0.tar.gz">simple-1.0.tar.gz</a>
<a data-yanked="test reason message" href="../../../packages/simple-2.0.tar.gz">simple-2.0.tar.gz</a>
<a href="../../../packages/simple-3.0rc0.tar.gz">simple-3.0rc0.tar.gz</a> and the test ran with
I think this happens because the prerelease is filtered before reaching the part of code we patched, so we get in the all yanked/no specifier case, but I am not 100% sure. I think that issue needs a separate fix that explicitly bypasses the need of passing I wanted to check what happened with
which I do not understand, given my index configuration. Side note: you mentioned to use |
This seems to check out with what I've noticed when I tried to fix this issue myself: This certainly does fix a part of #8262, just not the specific case with pre-releases. It's great to see someone working on this! :) |
Nice. In that case I think this is ready, and we should finish fixing #8262 in another PR. |
I just noticed this PR is changing |
Fixed, thank you for having spotted that. |
# PackageFinder returns earlier versions first, so we reverse. | ||
for ican in reversed(icans): | ||
if not all_yanked and ican.link.is_yanked: | ||
if (all_yanked and not is_pinned(specifier)) and ican.link.is_yanked: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this condition correct? It would seem that if some of the releases are not yanked, then we would want to skip yanked releases. Currently, this turns into condition:
if (False and ...) and ican.link.is_yanked:
...
which means that the yanked releases won't get ignored.
If I understand this correctly, it should instead be:
if (all_yanked and not is_pinned(specifier)) and ican.link.is_yanked: | |
if not (all_yanked and is_pinned(specifier)) and ican.link.is_yanked: |
Also I've noticed that is_pinned()
is called for each ican even though the specifier
argument is always the same. It's probably a premature optimization but it seems that this could be calculated just once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this condition correct? It would seem that if some of the releases are not yanked, then we would want to skip yanked releases.
This is actually a good point, I trusted tests/functional/test_install.py::test_ignore_yanked_file
for this but it turns out there might be a bug/undocumented feature in the code.
The test is pass, but only because the icans
returned by PackageFinder
looks like this:
ican=<InstallationCandidate('simple', <Version('3.0')>, <Link file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/packages/simple-3.0.tar.gz (from file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/indexes/yanked/simple/index.html)>)>
ican=<InstallationCandidate('simple', <Version('1.0')>, <Link file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/packages/simple-1.0.tar.gz (from file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/indexes/yanked/simple/index.html)>)>
ican=<InstallationCandidate('simple', <Version('2.0')>, <Link file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/packages/simple-2.0.tar.gz (from file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/indexes/yanked/simple/index.html)>)>
When reversed later, simple-2.0
becomes the first package, which passes the in-loop condition as it is not yanked and is therefore installed, making the test pass.
Altering the index used by the test and un-yanking simple-3.0
corrects the list order returned by PackageFinder
:
ican=<InstallationCandidate('simple', <Version('1.0')>, <Link file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/packages/simple-1.0.tar.gz (from file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/indexes/yanked/simple/index.html)>)>
ican=<InstallationCandidate('simple', <Version('2.0')>, <Link file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/packages/simple-2.0.tar.gz (from file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/indexes/yanked/simple/index.html)>)>
ican=<InstallationCandidate('simple', <Version('3.0')>, <Link file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/packages/simple-3.0.tar.gz (from file:///tmp/pytest-of-root/pytest-0/test_ignore_yanked_file0/data/indexes/yanked/simple/index.html)>)>
Hence, I am not sure on how/whether to fix this. If we can rely on this returned order, then both the code and the test are valid. Otherwise,
if not (all_yanked and is_pinned(specifier)) and ican.link.is_yanked:
This is fine for me as it passes both tests.
Also I've noticed that
is_pinned()
is called for each ican even though the specifier argument is always the same. It's probably a premature optimization but it seems that this could be calculated just once.
Good idea, I can adapt as proposed.
@uranusjr Should I implement both the proposed changes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's fix the is_pinned()
thing.
The yanked ordering one is more complicated. PackageFinder
ordering yanked versions last is a designed feature, defined here:
pip/src/pip/_internal/index/package_finder.py
Lines 473 to 539 in d81c65a
def _sort_key(self, candidate: InstallationCandidate) -> CandidateSortingKey: | |
""" | |
Function to pass as the `key` argument to a call to sorted() to sort | |
InstallationCandidates by preference. | |
Returns a tuple such that tuples sorting as greater using Python's | |
default comparison operator are more preferred. | |
The preference is as follows: | |
First and foremost, candidates with allowed (matching) hashes are | |
always preferred over candidates without matching hashes. This is | |
because e.g. if the only candidate with an allowed hash is yanked, | |
we still want to use that candidate. | |
Second, excepting hash considerations, candidates that have been | |
yanked (in the sense of PEP 592) are always less preferred than | |
candidates that haven't been yanked. Then: | |
If not finding wheels, they are sorted by version only. | |
If finding wheels, then the sort order is by version, then: | |
1. existing installs | |
2. wheels ordered via Wheel.support_index_min(self._supported_tags) | |
3. source archives | |
If prefer_binary was set, then all wheels are sorted above sources. | |
Note: it was considered to embed this logic into the Link | |
comparison operators, but then different sdist links | |
with the same version, would have to be considered equal | |
""" | |
valid_tags = self._supported_tags | |
support_num = len(valid_tags) | |
build_tag: BuildTag = () | |
binary_preference = 0 | |
link = candidate.link | |
if link.is_wheel: | |
# can raise InvalidWheelFilename | |
wheel = Wheel(link.filename) | |
try: | |
pri = -( | |
wheel.find_most_preferred_tag( | |
valid_tags, self._wheel_tag_preferences | |
) | |
) | |
except ValueError: | |
raise UnsupportedWheel( | |
"{} is not a supported wheel for this platform. It " | |
"can't be sorted.".format(wheel.filename) | |
) | |
if self._prefer_binary: | |
binary_preference = 1 | |
if wheel.build_tag is not None: | |
match = re.match(r"^(\d+)(.*)$", wheel.build_tag) | |
build_tag_groups = match.groups() | |
build_tag = (int(build_tag_groups[0]), build_tag_groups[1]) | |
else: # sdist | |
pri = -(support_num) | |
has_allowed_hash = int(link.is_hash_allowed(self._hashes)) | |
yank_value = -1 * int(link.is_yanked) # -1 for yanked. | |
return ( | |
has_allowed_hash, | |
yank_value, | |
binary_preference, | |
candidate.version, | |
pri, | |
build_tag, | |
) |
So it's OK to rely on this behaviour. The question is whether it's a good idea to do this when we have another solution. The answer to this is usually no, so let's change it. I actually have a feeling we might change the condition again later here specifically due to the prerelease issue (we'd want PackageFinder
to return those, but still only allow them if all stable releases are yanked, and I think that's only doable if we rely on ordering). But let's worry about that later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, done.
Is there anything else I could/should do on my side? Sorry if I keep asking, but I do not really know the PR workflow you use in this repository... |
def is_pinned(specifier: SpecifierSet) -> bool: | ||
for sp in specifier: | ||
if sp.operator == "===": | ||
return True | ||
if sp.operator != "==": | ||
continue | ||
if sp.version.endswith(".*"): | ||
continue | ||
return True | ||
return False | ||
|
||
pinned = is_pinned(specifier) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not for this PR, but we might want to synchronise this with how the other is_pinned
works in the codebase.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe there’re two other pinned logic that can be unified:
- In
provider.py
, there’s apinned = any(op[:2] == "==" for op in operators)
that can be tightened up. This one just offers a “hint” to the resolver so the current logic is not very careful with the definition of “pinned”, but since we have a proper implementation now it’s a good idea to reuse it. - There’s an
is_pinned
property inreq_install.py
. This one is a stright up bug that can only be triggered by the legacy resolver. But again, it’s a good idea to also fix it if it’s not too much effort. We don’t know if the code path will be needed by future code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it isn't put in provider.py
this PR I'm happy to submit a PR after this lands.
Currently pinned is considered more important than if a requirement is a backtracking cause in backtracking situations (I have a logical argument and tested evidence for this being faster), so being accurate about it probably reduces the chance of heavy backtracking scenarios.
Merging this, on the basis of being already approved by another maintainer! |
Thanks @albertosottile! ^>^ |
Currently, pip automatically installs yanked releases if it cannot find a regular release compatible with the platform. While this behavior is in line with PEP 592, it could cause some issues to users that do not expect to have inadvertently installed old versions/bugged versions of the requested package (see #10617 for
py2exe
).The change proposed in this PR is to suppress the automatic serving of yanked releases by pip for general requests. If the platform cannot be satisfied by any regular release available, pip should throw an error. Conversely, if the user deliberately requests a yanked release (via
==
or===
), pip should then serve the requested version.Fix #10617.