Repository: pypa/pip
Pull request #11691 (2023-01-03 15:25:41)

Mention build-backend in regular/editable install differences
<!---
Thank you for your soon to be pull request. Before you submit this, please
double check to make sure that you've added a news file fragment. In pip we
generate our NEWS.rst from multiple news fragment files, and all pull requests
require either a news file fragment or a marker to indicate they don't require
one.

To read more about adding a news file fragment for your PR, please check out
our documentation at: https://pip.pypa.io/en/latest/development/contributing/#news-entries
-->

In #11605, I was concerned that the difference between editable and regular installs is not sufficiently documented. I would make a change along the lines of this PR.

I'm not sure how to mark that this change does not require a new file fragment, but I'm leaning towards not needing one.



Patch:
diff --git a/docs/html/topics/local-project-installs.md b/docs/html/topics/local-project-installs.md
index 151035b00..bd70dbe2f 100644
--- a/docs/html/topics/local-project-installs.md
+++ b/docs/html/topics/local-project-installs.md
@@ -34,7 +34,7 @@ Editable installs allow you to install your project without copying any files. I
 With an editable install, you only need to perform a re-installation if you change the project metadata (eg: version, what scripts need to be generated etc). You will still need to run build commands when you need to perform a compilation for non-Python code in the project (eg: C extensions).
 
 ```{caution}
-It is possible to see behaviour differences between regular installs vs editable installs. In case you distribute the project as a "distribution package", users will see the behaviour of regular installs -- thus, it is important to ensure that regular installs work correctly.
+It is possible to see behaviour differences between regular installs vs editable installs. These differences depend on the build-backend, and you should check the build-backend documentation for the details. In case you distribute the project as a "distribution package", users will see the behaviour of regular installs -- thus, it is important to ensure that regular installs work correctly.
 ```
 
 ```{note}
diff --git a/news/11527.bugfix.rst b/news/11527.bugfix.rst
new file mode 100644
index 000000000..0185a804f
--- /dev/null
+++ b/news/11527.bugfix.rst
@@ -0,0 +1,2 @@
+Wheel cache behavior is restored to match previous versions, allowing the
+cache to find existing entries.
diff --git a/news/11623.bugfix.rst b/news/11623.bugfix.rst
new file mode 100644
index 000000000..45b8fe192
--- /dev/null
+++ b/news/11623.bugfix.rst
@@ -0,0 +1,1 @@
+Fix scripts path in isolated build environment on Debian.
diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py
index e67b868e8..24bfa870b 100644
--- a/src/pip/_internal/build_env.py
+++ b/src/pip/_internal/build_env.py
@@ -8,7 +8,6 @@ import site
 import sys
 import textwrap
 from collections import OrderedDict
-from sysconfig import get_paths
 from types import TracebackType
 from typing import TYPE_CHECKING, Iterable, List, Optional, Set, Tuple, Type
 
@@ -19,6 +18,7 @@ from pip._vendor.packaging.version import Version
 from pip import __file__ as pip_location
 from pip._internal.cli.spinners import open_spinner
 from pip._internal.locations import (
+    get_isolated_environment_bin_path,
     get_isolated_environment_lib_paths,
     get_platlib,
     get_purelib,
@@ -37,10 +37,7 @@ class _Prefix:
     def __init__(self, path: str) -> None:
         self.path = path
         self.setup = False
-        self.bin_dir = get_paths(
-            "nt" if os.name == "nt" else "posix_prefix",
-            vars={"base": path, "platbase": path},
-        )["scripts"]
+        self.bin_dir = get_isolated_environment_bin_path(path)
         self.lib_dirs = get_isolated_environment_lib_paths(path)
 
 
diff --git a/src/pip/_internal/index/package_finder.py b/src/pip/_internal/index/package_finder.py
index 9bf247f02..b6f8d57e8 100644
--- a/src/pip/_internal/index/package_finder.py
+++ b/src/pip/_internal/index/package_finder.py
@@ -1,14 +1,11 @@
 """Routines related to PyPI, indexes"""
 
-# The following comment should be removed at some point in the future.
-# mypy: strict-optional=False
-
 import enum
 import functools
 import itertools
 import logging
 import re
-from typing import FrozenSet, Iterable, List, Optional, Set, Tuple, Union
+from typing import TYPE_CHECKING, FrozenSet, Iterable, List, Optional, Set, Tuple, Union
 
 from pip._vendor.packaging import specifiers
 from pip._vendor.packaging.tags import Tag
@@ -39,6 +36,9 @@ from pip._internal.utils.misc import build_netloc
 from pip._internal.utils.packaging import check_requires_python
 from pip._internal.utils.unpacking import SUPPORTED_EXTENSIONS
 
+if TYPE_CHECKING:
+    from pip._vendor.typing_extensions import TypeGuard
+
 __all__ = ["FormatControl", "BestCandidateResult", "PackageFinder"]
 
 
@@ -251,7 +251,7 @@ class LinkEvaluator:
 
 def filter_unallowed_hashes(
     candidates: List[InstallationCandidate],
-    hashes: Hashes,
+    hashes: Optional[Hashes],
     project_name: str,
 ) -> List[InstallationCandidate]:
     """
@@ -540,6 +540,7 @@ class CandidateEvaluator:
                 binary_preference = 1
             if wheel.build_tag is not None:
                 match = re.match(r"^(\d+)(.*)$", wheel.build_tag)
+                assert match is not None, "guaranteed by filename validation"
                 build_tag_groups = match.groups()
                 build_tag = (int(build_tag_groups[0]), build_tag_groups[1])
         else:  # sdist
@@ -942,43 +943,46 @@ class PackageFinder:
                 "No matching distribution found for {}".format(req)
             )
 
-        best_installed = False
-        if installed_version and (
-            best_candidate is None or best_candidate.version <= installed_version
-        ):
-            best_installed = True
+        def _should_install_candidate(
+            candidate: Optional[InstallationCandidate],
+        ) -> "TypeGuard[InstallationCandidate]":
+            if installed_version is None:
+                return True
+            if best_candidate is None:
+                return False
+            return best_candidate.version > installed_version
 
         if not upgrade and installed_version is not None:
-            if best_installed:
+            if _should_install_candidate(best_candidate):
                 logger.debug(
-                    "Existing installed version (%s) is most up-to-date and "
-                    "satisfies requirement",
+                    "Existing installed version (%s) satisfies requirement "
+                    "(most up-to-date version is %s)",
                     installed_version,
+                    best_candidate.version,
                 )
             else:
                 logger.debug(
-                    "Existing installed version (%s) satisfies requirement "
-                    "(most up-to-date version is %s)",
+                    "Existing installed version (%s) is most up-to-date and "
+                    "satisfies requirement",
                     installed_version,
-                    best_candidate.version,
                 )
             return None
 
-        if best_installed:
-            # We have an existing version, and its the best version
+        if _should_install_candidate(best_candidate):
             logger.debug(
-                "Installed version (%s) is most up-to-date (past versions: %s)",
-                installed_version,
+                "Using version %s (newest of versions: %s)",
+                best_candidate.version,
                 _format_versions(best_candidate_result.iter_applicable()),
             )
-            raise BestVersionAlreadyInstalled
+            return best_candidate
 
+        # We have an existing version, and its the best version
         logger.debug(
-            "Using version %s (newest of versions: %s)",
-            best_candidate.version,
+            "Installed version (%s) is most up-to-date (past versions: %s)",
+            installed_version,
             _format_versions(best_candidate_result.iter_applicable()),
         )
-        return best_candidate
+        raise BestVersionAlreadyInstalled
 
 
 def _find_name_version_sep(fragment: str, canonical_name: str) -> int:
diff --git a/src/pip/_internal/locations/__init__.py b/src/pip/_internal/locations/__init__.py
index 516bd6078..496844be1 100644
--- a/src/pip/_internal/locations/__init__.py
+++ b/src/pip/_internal/locations/__init__.py
@@ -25,9 +25,10 @@ __all__ = [
     "USER_CACHE_DIR",
     "get_bin_prefix",
     "get_bin_user",
+    "get_isolated_environment_bin_path",
+    "get_isolated_environment_lib_paths",
     "get_major_minor_version",
     "get_platlib",
-    "get_isolated_environment_lib_paths",
     "get_purelib",
     "get_scheme",
     "get_src_prefix",
@@ -526,3 +527,7 @@ def get_isolated_environment_lib_paths(prefix: str) -> List[str]:
         _log_context(prefix=prefix)
 
     return old_lib_paths
+
+
+def get_isolated_environment_bin_path(prefix: str) -> str:
+    return _sysconfig.get_isolated_environment_paths(prefix)["scripts"]
diff --git a/src/pip/_internal/locations/_sysconfig.py b/src/pip/_internal/locations/_sysconfig.py
index 698215720..38e400f94 100644
--- a/src/pip/_internal/locations/_sysconfig.py
+++ b/src/pip/_internal/locations/_sysconfig.py
@@ -213,10 +213,13 @@ def get_platlib() -> str:
     return sysconfig.get_paths()["platlib"]
 
 
-def get_isolated_environment_lib_paths(prefix: str) -> typing.Tuple[str, str]:
-    vars = {"base": prefix, "platbase": prefix}
+def get_isolated_environment_paths(prefix: str) -> typing.Dict[str, str]:
+    variables = {"base": prefix, "platbase": prefix}
     if "venv" in sysconfig.get_scheme_names():
-        paths = sysconfig.get_paths(vars=vars, scheme="venv")
-    else:
-        paths = sysconfig.get_paths(vars=vars)
+        return sysconfig.get_paths(vars=variables, scheme="venv")
+    return sysconfig.get_paths(vars=variables)
+
+
+def get_isolated_environment_lib_paths(prefix: str) -> typing.Tuple[str, str]:
+    paths = get_isolated_environment_paths(prefix)
     return (paths["purelib"], paths["platlib"])
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
index c7c4b0e9b..a1e4d5a08 100644
--- a/src/pip/_internal/models/link.py
+++ b/src/pip/_internal/models/link.py
@@ -79,6 +79,9 @@ class LinkHash:
         name, value = match.groups()
         return cls(name=name, value=value)
 
+    def as_dict(self) -> Dict[str, str]:
+        return {self.name: self.value}
+
     def as_hashes(self) -> Hashes:
         """Return a Hashes instance which checks only for the current hash."""
         return Hashes({self.name: [self.value]})
@@ -165,7 +168,6 @@ class Link(KeyBasedCompareMixin):
         "requires_python",
         "yanked_reason",
         "dist_info_metadata",
-        "link_hash",
         "cache_link_parsing",
         "egg_fragment",
     ]
@@ -177,7 +179,6 @@ class Link(KeyBasedCompareMixin):
         requires_python: Optional[str] = None,
         yanked_reason: Optional[str] = None,
         dist_info_metadata: Optional[str] = None,
-        link_hash: Optional[LinkHash] = None,
         cache_link_parsing: bool = True,
         hashes: Optional[Mapping[str, str]] = None,
     ) -> None:
@@ -200,16 +201,11 @@ class Link(KeyBasedCompareMixin):
             attribute, if present, in a simple repository HTML link. This may be parsed
             into its own `Link` by `self.metadata_link()`. See PEP 658 for more
             information and the specification.
-        :param link_hash: a checksum for the content the link points to. If not
-            provided, this will be extracted from the link URL, if the URL has
-            any checksum.
         :param cache_link_parsing: A flag that is used elsewhere to determine
-                                   whether resources retrieved from this link
-                                   should be cached. PyPI index urls should
-                                   generally have this set to False, for
-                                   example.
+            whether resources retrieved from this link should be cached. PyPI
+            URLs should generally have this set to False, for example.
         :param hashes: A mapping of hash names to digests to allow us to
-                       determine the validity of a download.
+            determine the validity of a download.
         """
 
         # url can be a UNC windows share
@@ -220,13 +216,18 @@ class Link(KeyBasedCompareMixin):
         # Store the url as a private attribute to prevent accidentally
         # trying to set a new value.
         self._url = url
-        self._hashes = hashes if hashes is not None else {}
+
+        link_hash = LinkHash.split_hash_name_and_value(url)
+        hashes_from_link = {} if link_hash is None else link_hash.as_dict()
+        if hashes is None:
+            self._hashes = hashes_from_link
+        else:
+            self._hashes = {**hashes, **hashes_from_link}
 
         self.comes_from = comes_from
         self.requires_python = requires_python if requires_python else None
         self.yanked_reason = yanked_reason
         self.dist_info_metadata = dist_info_metadata
-        self.link_hash = link_hash or LinkHash.split_hash_name_and_value(self._url)
 
         super().__init__(key=url, defining_class=Link)
 
@@ -401,29 +402,26 @@ class Link(KeyBasedCompareMixin):
         if self.dist_info_metadata is None:
             return None
         metadata_url = f"{self.url_without_fragment}.metadata"
-        link_hash: Optional[LinkHash] = None
         # If data-dist-info-metadata="true" is set, then the metadata file exists,
         # but there is no information about its checksum or anything else.
         if self.dist_info_metadata != "true":
             link_hash = LinkHash.split_hash_name_and_value(self.dist_info_metadata)
-        return Link(metadata_url, link_hash=link_hash)
+        else:
+            link_hash = None
+        if link_hash is None:
+            return Link(metadata_url)
+        return Link(metadata_url, hashes=link_hash.as_dict())
 
-    def as_hashes(self) -> Optional[Hashes]:
-        if self.link_hash is not None:
-            return self.link_hash.as_hashes()
-        return None
+    def as_hashes(self) -> Hashes:
+        return Hashes({k: [v] for k, v in self._hashes.items()})
 
     @property
     def hash(self) -> Optional[str]:
-        if self.link_hash is not None:
-            return self.link_hash.value
-        return None
+        return next(iter(self._hashes.values()), None)
 
     @property
     def hash_name(self) -> Optional[str]:
-        if self.link_hash is not None:
-            return self.link_hash.name
-        return None
+        return next(iter(self._hashes), None)
 
     @property
     def show_url(self) -> str:
@@ -452,15 +450,15 @@ class Link(KeyBasedCompareMixin):
 
     @property
     def has_hash(self) -> bool:
-        return self.link_hash is not None
+        return bool(self._hashes)
 
     def is_hash_allowed(self, hashes: Optional[Hashes]) -> bool:
         """
         Return True if the link has a hash and it is allowed by `hashes`.
         """
-        if self.link_hash is None:
+        if hashes is None:
             return False
-        return self.link_hash.is_hash_allowed(hashes)
+        return any(hashes.is_hash_allowed(k, v) for k, v in self._hashes.items())
 
 
 class _CleanResult(NamedTuple):
diff --git a/src/pip/_internal/self_outdated_check.py b/src/pip/_internal/self_outdated_check.py
index 9e2149c52..41cc42c56 100644
--- a/src/pip/_internal/self_outdated_check.py
+++ b/src/pip/_internal/self_outdated_check.py
@@ -133,7 +133,7 @@ class UpgradePrompt:
         return Group(
             Text(),
             Text.from_markup(
-                f"{notice} A new release of pip available: "
+                f"{notice} A new release of pip is available: "
                 f"[red]{self.old}[reset] -> [green]{self.new}[reset]"
             ),
             Text.from_markup(
@@ -155,7 +155,7 @@ def was_installed_by_pip(pkg: str) -> bool:
 
 def _get_current_remote_pip_version(
     session: PipSession, options: optparse.Values
-) -> str:
+) -> Optional[str]:
     # Lets use PackageFinder to see what the latest pip version is
     link_collector = LinkCollector.create(
         session,
@@ -176,7 +176,7 @@ def _get_current_remote_pip_version(
     )
     best_candidate = finder.find_best_candidate("pip").best_candidate
     if best_candidate is None:
-        return
+        return None
 
     return str(best_candidate.version)
 
@@ -186,11 +186,14 @@ def _self_version_check_logic(
     state: SelfCheckState,
     current_time: datetime.datetime,
     local_version: DistributionVersion,
-    get_remote_version: Callable[[], str],
+    get_remote_version: Callable[[], Optional[str]],
 ) -> Optional[UpgradePrompt]:
     remote_version_str = state.get(current_time)
     if remote_version_str is None:
         remote_version_str = get_remote_version()
+        if remote_version_str is None:
+            logger.debug("No remote pip version found")
+            return None
         state.set(remote_version_str, current_time)
 
     remote_version = parse_version(remote_version_str)


Test patch:
diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py
index f1f0141c7..f27daa266 100644
--- a/tests/unit/test_cache.py
+++ b/tests/unit/test_cache.py
@@ -1,7 +1,7 @@
 import os
 from pathlib import Path
 
-from pip._vendor.packaging.tags import Tag
+from pip._vendor.packaging.tags import Tag, interpreter_name, interpreter_version
 
 from pip._internal.cache import WheelCache, _hash_dict
 from pip._internal.models.format_control import FormatControl
@@ -52,6 +52,48 @@ def test_cache_hash() -> None:
     assert h == "f83b32dfa27a426dec08c21bf006065dd003d0aac78e7fc493d9014d"
 
 
+def test_link_to_cache(tmpdir: Path) -> None:
+    """
+    Test that Link.from_json() produces Links with consistent cache
+    locations
+    """
+    wc = WheelCache(os.fspath(tmpdir), FormatControl())
+    # Define our expectations for stable cache path.
+    i_name = interpreter_name()
+    i_version = interpreter_version()
+    key_parts = {
+        "url": "https://files.pythonhosted.org/packages/a6/91/"
+        "86a6eac449ddfae239e93ffc1918cf33fd9bab35c04d1e963b311e347a73/"
+        "netifaces-0.11.0.tar.gz",
+        "sha256": "043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32",
+        "interpreter_name": i_name,
+        "interpreter_version": i_version,
+    }
+    expected_hash = _hash_dict(key_parts)
+    parts = [
+        expected_hash[:2],
+        expected_hash[2:4],
+        expected_hash[4:6],
+        expected_hash[6:],
+    ]
+    pathed_hash = os.path.join(*parts)
+    # Check working from a Link produces the same result.
+    file_data = {
+        "filename": "netifaces-0.11.0.tar.gz",
+        "hashes": {
+            "sha256": key_parts["sha256"],
+        },
+        "requires-python": "",
+        "url": key_parts["url"],
+        "yanked": False,
+    }
+    page_url = "https://pypi.org/simple/netifaces/"
+    link = Link.from_json(file_data=file_data, page_url=page_url)
+    assert link
+    path = wc.get_path_for_link(link)
+    assert pathed_hash in path
+
+
 def test_get_cache_entry(tmpdir: Path) -> None:
     wc = WheelCache(os.fspath(tmpdir), FormatControl())
     persi_link = Link("https://g.c/o/r/persi")
diff --git a/tests/unit/test_collector.py b/tests/unit/test_collector.py
index 55676a4fc..47307c00e 100644
--- a/tests/unit/test_collector.py
+++ b/tests/unit/test_collector.py
@@ -6,7 +6,7 @@ import re
 import uuid
 from pathlib import Path
 from textwrap import dedent
-from typing import List, Optional, Tuple
+from typing import Dict, List, Optional, Tuple
 from unittest import mock
 
 import pytest
@@ -538,7 +538,7 @@ def test_parse_links_json() -> None:
         metadata_link.url
         == "https://example.com/files/holygrail-1.0-py3-none-any.whl.metadata"
     )
-    assert metadata_link.link_hash == LinkHash("sha512", "aabdd41")
+    assert metadata_link._hashes == {"sha512": "aabdd41"}
 
 
 @pytest.mark.parametrize(
@@ -575,41 +575,41 @@ _pkg1_requirement = Requirement("pkg1==1.0")
 
 
 @pytest.mark.parametrize(
-    "anchor_html, expected, link_hash",
+    "anchor_html, expected, hashes",
     [
         # Test not present.
         (
             '<a href="/pkg1-1.0.tar.gz"></a>',
             None,
-            None,
+            {},
         ),
         # Test with value "true".
         (
             '<a href="/pkg1-1.0.tar.gz" data-dist-info-metadata="true"></a>',
             "true",
-            None,
+            {},
         ),
         # Test with a provided hash value.
         (
             '<a href="/pkg1-1.0.tar.gz" data-dist-info-metadata="sha256=aa113592bbe"></a>',  # noqa: E501
             "sha256=aa113592bbe",
-            None,
+            {},
         ),
         # Test with a provided hash value for both the requirement as well as metadata.
         (
             '<a href="/pkg1-1.0.tar.gz#sha512=abc132409cb" data-dist-info-metadata="sha256=aa113592bbe"></a>',  # noqa: E501
             "sha256=aa113592bbe",
-            LinkHash("sha512", "abc132409cb"),
+            {"sha512": "abc132409cb"},
         ),
     ],
 )
 def test_parse_links__dist_info_metadata(
     anchor_html: str,
     expected: Optional[str],
-    link_hash: Optional[LinkHash],
+    hashes: Dict[str, str],
 ) -> None:
     link = _test_parse_links_data_attribute(anchor_html, "dist_info_metadata", expected)
-    assert link.link_hash == link_hash
+    assert link._hashes == hashes
 
 
 def test_parse_links_caches_same_page_by_url() -> None: