diff --git a/news/12308.bugfix.rst b/news/12308.bugfix.rst new file mode 100644 index 00000000000..4ac3f54bc69 --- /dev/null +++ b/news/12308.bugfix.rst @@ -0,0 +1,4 @@ +When using the ``importlib.metadata`` backend (the default on Python 3.11+), +``pip list`` does not show installed egg distributions more than once anymore. +Additionally, egg distributions whose parent directory was in ``sys.path`` but +the egg themselves were not in ``sys.path`` are not detected anymore. diff --git a/news/13010.removal.rst b/news/13010.removal.rst index 1b665cc20bf..7af2f09b3ac 100644 --- a/news/13010.removal.rst +++ b/news/13010.removal.rst @@ -1,2 +1 @@ -On python 3.14+, the ``pkg_resources`` metadata backend is not used anymore, -and pip does not attempt to detect installed ``.egg`` distributions. +On python 3.14+, the ``pkg_resources`` metadata backend cannot be used anymore. diff --git a/src/pip/_internal/metadata/base.py b/src/pip/_internal/metadata/base.py index 9eabcdb278b..ea5a0756dd7 100644 --- a/src/pip/_internal/metadata/base.py +++ b/src/pip/_internal/metadata/base.py @@ -231,7 +231,9 @@ def installed_as_egg(self) -> bool: location = self.location if not location: return False - return location.endswith(".egg") + # XXX if the distribution is a zipped egg, location has a trailing / + # so we resort to pathlib.Path to check the suffix in a reliable way. + return pathlib.Path(location).suffix == ".egg" @property def installed_with_setuptools_egg_info(self) -> bool: diff --git a/src/pip/_internal/metadata/importlib/_envs.py b/src/pip/_internal/metadata/importlib/_envs.py index c7b09d215e3..314e75e6731 100644 --- a/src/pip/_internal/metadata/importlib/_envs.py +++ b/src/pip/_internal/metadata/importlib/_envs.py @@ -1,11 +1,9 @@ -import functools import importlib.metadata import logging import os import pathlib import sys import zipfile -import zipimport from typing import Iterator, List, Optional, Sequence, Set, Tuple from pip._vendor.packaging.utils import ( @@ -16,7 +14,6 @@ ) from pip._internal.metadata.base import BaseDistribution, BaseEnvironment -from pip._internal.utils.deprecation import deprecated from pip._internal.utils.filetypes import WHEEL_EXTENSION from ._compat import BadMetadata, BasePath, get_dist_canonical_name, get_info_location @@ -88,7 +85,7 @@ def find(self, location: str) -> Iterator[BaseDistribution]: installed_location = info_location.parent yield Distribution(dist, info_location, installed_location) - def find_linked(self, location: str) -> Iterator[BaseDistribution]: + def find_legacy_editables(self, location: str) -> Iterator[BaseDistribution]: """Read location in egg-link files and return distributions in there. The path should be a directory; otherwise this returns nothing. This @@ -112,54 +109,6 @@ def find_linked(self, location: str) -> Iterator[BaseDistribution]: for dist, info_location in self._find_impl(target_location): yield Distribution(dist, info_location, path) - def _find_eggs_in_dir(self, location: str) -> Iterator[BaseDistribution]: - from pip._vendor.pkg_resources import find_distributions - - from pip._internal.metadata import pkg_resources as legacy - - with os.scandir(location) as it: - for entry in it: - if not entry.name.endswith(".egg"): - continue - for dist in find_distributions(entry.path): - yield legacy.Distribution(dist) - - def _find_eggs_in_zip(self, location: str) -> Iterator[BaseDistribution]: - from pip._vendor.pkg_resources import find_eggs_in_zip - - from pip._internal.metadata import pkg_resources as legacy - - try: - importer = zipimport.zipimporter(location) - except zipimport.ZipImportError: - return - for dist in find_eggs_in_zip(importer, location): - yield legacy.Distribution(dist) - - def find_eggs(self, location: str) -> Iterator[BaseDistribution]: - """Find eggs in a location. - - This actually uses the old *pkg_resources* backend. We likely want to - deprecate this so we can eventually remove the *pkg_resources* - dependency entirely. Before that, this should first emit a deprecation - warning for some versions when using the fallback since importing - *pkg_resources* is slow for those who don't need it. - """ - if os.path.isdir(location): - yield from self._find_eggs_in_dir(location) - if zipfile.is_zipfile(location): - yield from self._find_eggs_in_zip(location) - - -@functools.lru_cache(maxsize=None) # Warn a distribution exactly once. -def _emit_egg_deprecation(location: Optional[str]) -> None: - deprecated( - reason=f"Loading egg at {location} is deprecated.", - replacement="to use pip for package installation", - gone_in="25.1", - issue=12330, - ) - class Environment(BaseEnvironment): def __init__(self, paths: Sequence[str]) -> None: @@ -179,12 +128,7 @@ def _iter_distributions(self) -> Iterator[BaseDistribution]: finder = _DistributionFinder() for location in self._paths: yield from finder.find(location) - if sys.version_info < (3, 14): - for dist in finder.find_eggs(location): - _emit_egg_deprecation(dist.location) - yield dist - # This must go last because that's how pkg_resources tie-breaks. - yield from finder.find_linked(location) + yield from finder.find_legacy_editables(location) def get_distribution(self, name: str) -> Optional[BaseDistribution]: canonical_name = canonicalize_name(name) diff --git a/src/pip/_internal/req/req_uninstall.py b/src/pip/_internal/req/req_uninstall.py index e48c53eb1e4..a41082317d1 100644 --- a/src/pip/_internal/req/req_uninstall.py +++ b/src/pip/_internal/req/req_uninstall.py @@ -505,10 +505,13 @@ def from_dist(cls, dist: BaseDistribution) -> "UninstallPathSet": # package installed by easy_install # We cannot match on dist.egg_name because it can slightly vary # i.e. setuptools-0.6c11-py2.6.egg vs setuptools-0.6rc11-py2.6.egg - paths_to_remove.add(dist_location) - easy_install_egg = os.path.split(dist_location)[1] + # XXX We use normalized_dist_location because dist_location my contain + # a trailing / if the distribution is a zipped egg + # (which is not a directory). + paths_to_remove.add(normalized_dist_location) + easy_install_egg = os.path.split(normalized_dist_location)[1] easy_install_pth = os.path.join( - os.path.dirname(dist_location), + os.path.dirname(normalized_dist_location), "easy-install.pth", ) paths_to_remove.add_pth(easy_install_pth, "./" + easy_install_egg) diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py index db2d25f2233..92886566f88 100644 --- a/tests/functional/test_uninstall.py +++ b/tests/functional/test_uninstall.py @@ -628,10 +628,6 @@ def test_uninstall_with_symlink( assert symlink_target.stat().st_mode == st_mode -@pytest.mark.skipif( - "sys.version_info >= (3, 14)", - reason="Uninstall of .egg distributions not supported in Python 3.14+", -) def test_uninstall_setuptools_develop_install( script: PipTestEnvironment, data: TestData ) -> None: @@ -642,11 +638,19 @@ def test_uninstall_setuptools_develop_install( script.assert_installed(FSPkg="0.1.dev0") # Uninstall both develop and install uninstall = script.pip("uninstall", "FSPkg", "-y") - assert any(p.suffix == ".egg" for p in uninstall.files_deleted), str(uninstall) uninstall2 = script.pip("uninstall", "FSPkg", "-y") - assert ( + # Depending on the metadata backend, the egg-link will be uninstalled first + # or second, so we use xor in the assertions below. + assert (join(script.site_packages, "FSPkg.egg-link") in uninstall.files_deleted) ^ ( join(script.site_packages, "FSPkg.egg-link") in uninstall2.files_deleted - ), str(uninstall2) + ) + assert any( + p.name.startswith("FSPkg") and p.suffix == ".egg" + for p in uninstall.files_deleted + ) ^ any( + p.name.startswith("FSPkg") and p.suffix == ".egg" + for p in uninstall2.files_deleted + ) script.assert_not_installed("FSPkg")