Repository: pypa/pip

Issue:
Python 3.10: pip installs wheel for ancient Python versions
### Description

I am the current maintainer of `py2exe` https://github.com/py2exe/py2exe and I still have not released a version of the package for Python 3.10, 

However, users reported that they were able to install the package on their system. When questioned, they reported that `pip` first tries to install from some old sources (`py2exe-0.10.0.2.tar.gz`). I am sure this is my fault and `setup.py` is not proper there, and I intend to delete this rogue release.

Unfortunately, after this failed attempt, `pip` picks an even older wheel which is not compatible at all with 3.10 (`py2exe-0.9.2.2-py33.py34-none-any.whl`) and claims that the package was successfully installed.

I can delete the rogue 0.10.0.2 source archive, but I would like not to delete also all the releases before that one. Could you tell me why this is happening and if is there anything, besides deleting all past releases, that I can do?

Thank you for your assistance

### Expected behavior

`pip` should either say that no version compatible with the platform was found or try to install from the source archive and fail due to the lack of dependencies/compiler.

### pip version

21.3.1

### Python version

3.10.0

### OS

Microsoft Windows [Version 10.0.17763.2183]

### How to Reproduce

`pip install py2exe`

### Output

```sh-session
C:\>pip install py2exe
Collecting py2exe
  Downloading py2exe-0.10.0.2.tar.gz (104 kB)
     |████████████████████████████████| 104 kB 6.8 MB/s
  Preparing metadata (setup.py) ... error
  ERROR: Command errored out with exit status 1:
   command: 'C:\Python\python.exe' -c 'import io, os, sys, setuptools, tokenize; sys.argv[0] = '"'"'C:\\Users\\ContainerAdministrator\\AppData\\Local\\Temp\\pip-install-lyc0or4x\\py2exe_b12f490fb2d949
048ed23d41020dcdaf\\setup.py'"'"'; __file__='"'"'C:\\Users\\ContainerAdministrator\\AppData\\Local\\Temp\\pip-install-lyc0or4x\\py2exe_b12f490fb2d949048ed23d41020dcdaf\\setup.py'"'"';f = getattr(token
ize, '"'"'open'"'"', open)(__file__) if os.path.exists(__file__) else io.StringIO('"'"'from setuptools import setup; setup()'"'"');code = f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(
compile(code, __file__, '"'"'exec'"'"'))' egg_info --egg-base 'C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-pip-egg-info-wcv280uo'
       cwd: C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\
  Complete output (17 lines):
  Traceback (most recent call last):
    File "<string>", line 1, in <module>
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\setup.py", line 17, in <module>
      from py2exe.py2exe_distutils import Dist, Interpreter, BuildInterpreters
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\py2exe\__init__.py", line 9, in <module>
      patch_distutils()
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\py2exe\patch_distutils.py", line 68, in patch_distutils
      from . import distutils_buildexe
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\py2exe\distutils_buildexe.py", line 91, in <module>
      from . import runtime
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\py2exe\runtime.py", line 3, in <module>
      from .dllfinder import Scanner, pydll
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\py2exe\dllfinder.py", line 6, in <module>
      from . import pescan
    File "C:\Users\ContainerAdministrator\AppData\Local\Temp\pip-install-lyc0or4x\py2exe_b12f490fb2d949048ed23d41020dcdaf\py2exe\pescan.py", line 12, in <module>
      import pefile
  ModuleNotFoundError: No module named 'pefile'
  ----------------------------------------
WARNING: Discarding https://files.pythonhosted.org/packages/7d/2b/5bb512a67aaa465f8629f2c0d759f56b8136a973b355db32870beb5c76af/py2exe-0.10.0.2.tar.gz#sha256=40d785a92c85908c4829bcfd4ae9985d20dcdb805e5
0097c8cba78608b06a81c (from https://pypi.org/simple/py2exe/). Command errored out with exit status 1: python setup.py egg_info Check the logs for full command output.
  Downloading py2exe-0.9.2.2-py33.py34-none-any.whl (270 kB)
     |████████████████████████████████| 270 kB 6.4 MB/s
Installing collected packages: py2exe
Successfully installed py2exe-0.9.2.2
```


### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).


Discussion:
Can you run `pip install -vvv py2exe`, put the output of that in a GitHub Gist, and share the link to that Gist here?
Link to the gist: https://gist.github.com/albertosottile/242434120f73d7e73ed2063ef954bc16
Python 3.10 is compatible with `py34` wheels, see `py -m pip debug -v` for the full list of supported tags. So there's nothing to tell pip that it shouldn't install this version. Later versions of py2exe appear to have C extensions,so they use `cpXY` tags - and CPython 3.10 is *not* compatible with `cp37` wheels, so pip skips those.

If the user is saying "install py2exe", arguably 0.9.2.2 is better than saying "I can't install py2exe". So pip's behaviour is right (although I agree it's surprising, and likely not what the user *actually* wanted). The user could say `py -m pip install "py2exe>=0.10"` if they actually do want something from the 0.10 series or later - and then they will get told "sorry, cannot find anything I can install" because building from source doesn't work and there's no compatible wheel.
Thanks for your explanation, I now understand the behavior, I did not expect that the old `py` tags were compatible with newer Python versions. However, `py2exe` always shipped C extensions so, it is quite clear to me the old wheels were mistagged and should have been, instead `cp33.cp34`. A quick inspection of the wheel archive confirmed that.

is there a way to stop this behavior without actually deleting the releases and the wheels from PyPI? Is it possible to mark some old releases as obsolete, so that `pip install py2exe` does not pick up those but an experienced user can still run `pip install [OPT] py2exe==0.9.2.2` if they really want (something like `--pre`, but in the opposite direction)?
Yanking should work right? Pip still skip over them normally but if you specify the exact version it will still use it?
I learned a lot today, I did not know yanked releases were accessible via `pip`. I will give it a try on TestPyPI now. Thanks for your support.
Looks like it does not work, I deleted the rogue source archive of 0.10.0.2 and yanked every release before 0.10, but now this happens:
```
C:\>pip install -i https://test.pypi.org/simple py2exe
Looking in indexes: https://test.pypi.org/simple
Collecting py2exe
  Downloading https://test-files.pythonhosted.org/packages/15/94/cb55e5e73067547e0fe00b38944931a181c73339e29d905ed41d90e94cc8/py2exe-0.9.2.2-py33.py34-none-any.whl (270 kB)
     |████████████████████████████████| 270 kB 6.8 MB/s
WARNING: The candidate selected for download or install is a yanked version: 'py2exe' candidate (version 0.9.2.2 at https://test-files.pythonhosted.org/packages/15/94/cb55e5e73067547e0fe00b38944931a18
1c73339e29d905ed41d90e94cc8/py2exe-0.9.2.2-py33.py34-none-any.whl#sha256=a77ec1565ee61daaef963a8c40d695adc29819e45c8dcfbe122f8ceac30a5be9 (from https://test.pypi.org/simple/py2exe/))
Reason for being yanked: Outdated
Installing collected packages: py2exe
Successfully installed py2exe-0.9.2.2
```

It seems that `pip` still prefers a yanked release over nothing.
Yeah because (quoting PEP 592)

> An installer MUST ignore yanked releases, if the selection constraints can be satisfied with a non-yanked version, and MAY refuse to use a yanked release even if it means that the request cannot be satisfied at all. An implementation SHOULD choose a policy that follows the spirit of the intention above, and that prevents "new" dependencies on yanked releases/files.

> What this means is left up to the specific installer, to decide how to best fit into the overall usage of their installer.

And the approach pip currently implements is "use a yanked distribution if no non-yanked distributions satisfy the user request". Which is technically allowed by the PEP, but probably not the best approach.
Ah my mistake,  I thought there was something special about selecting an exact version but I guess pip just chooses a yanked packages in that case because it's the only one available.
There's a related open issue on this as well. As described in https://github.com/pypa/pip/issues/8262#issuecomment-886737619, it is likely pip should tighten up the logic to ignore yanked version entirely unless the version range specifiers leading to the distribution selection contains `==` (but not containing a wildcard) or `===`. But no-one has cared about this enough to submit a fix yet.
Would you be interested in a PR regarding this? Technically, I think the fix might be limited to patching https://github.com/pypa/pip/commit/ffb3d1bc761d74583b5c59275bfda997afeebc03 as it follows:

Current:
https://github.com/pypa/pip/blob/e46888b468a42a3bc52518aaef7f351b39131499/src/pip/_internal/resolution/resolvelib/factory.py#L276-L284

After (**EDIT**):
```python
            # PackageFinder returns earlier versions first, so we reverse.
            for ican in reversed(icans):
                if (all_yanked and not specifier) and ican.link.is_yanked:
                    continue
```

**EDIT**: the solution might not be that trivial. One might skip unless there's a specifier (see above), or unless `icans` contains only one candidate, or a combination of the two.

However, I guess the core of the issue is more political. Is this is a behavior change that you would like to have in `pip`?

I think this would be a good change since it fixes a real issue (the one we have here), and those negatively impacted have a relatively easy way out (by adding a `package==version` pin). This needs to go through the normal deprecation process, but we can talk about how that'd work in the PR.

Regarding the implementation, the simplest design would be to only allow a yanked distribution _only_ if the user uses `==` (or `===`). This can be pretty easily done by checking the content of `specifier`.

Resolving patch:
diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py
--- a/src/pip/_internal/resolution/resolvelib/factory.py
+++ b/src/pip/_internal/resolution/resolvelib/factory.py
@@ -273,14 +273,27 @@ def iter_index_candidate_infos() -> Iterator[IndexCandidateInfo]:
             )
             icans = list(result.iter_applicable())
 
-            # PEP 592: Yanked releases must be ignored unless only yanked
-            # releases can satisfy the version range. So if this is false,
-            # all yanked icans need to be skipped.
+            # PEP 592: Yanked releases are ignored unless the specifier
+            # explicitely pins a version (via '==' or '===') that can be
+            # solely satisfied by a yanked release.
             all_yanked = all(ican.link.is_yanked for ican in icans)
 
+            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)
+
             # PackageFinder returns earlier versions first, so we reverse.
             for ican in reversed(icans):
-                if not all_yanked and ican.link.is_yanked:
+                if not (all_yanked and pinned) and ican.link.is_yanked:
                     continue
                 func = functools.partial(
                     self._make_candidate_from_link,


Test patch:
diff --git a/tests/data/indexes/yanked_all/simple/index.html b/tests/data/indexes/yanked_all/simple/index.html
new file mode 100644
--- /dev/null
+++ b/tests/data/indexes/yanked_all/simple/index.html
@@ -0,0 +1,7 @@
+<html>
+  <body>
+    <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 data-yanked="test reason message" href="../../../packages/simple-3.0.tar.gz">simple-3.0.tar.gz</a>
+  </body>
+</html>
diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py
--- a/tests/functional/test_install.py
+++ b/tests/functional/test_install.py
@@ -2030,6 +2030,24 @@ def test_install_yanked_file_and_print_warning(script, data):
     assert "Successfully installed simple-3.0\n" in result.stdout, str(result)
 
 
+def test_error_all_yanked_files_and_no_pin(script, data):
+    """
+    Test raising an error if there are only "yanked" files available and no pin
+    """
+    result = script.pip(
+        "install",
+        "simple",
+        "--index-url",
+        data.index_url("yanked_all"),
+        expect_error=True,
+    )
+    # Make sure an error is raised
+    assert (
+        result.returncode == 1
+        and "ERROR: No matching distribution found for simple\n" in result.stderr
+    ), str(result)
+
+
 @pytest.mark.parametrize(
     "install_args",
     [