Skip to content

Remove additional detection of installed egg distributions when using the imporlib.metadata backend #12308

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

Merged
merged 5 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions news/12308.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions news/13010.removal.rst
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion src/pip/_internal/metadata/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 2 additions & 58 deletions src/pip/_internal/metadata/importlib/_envs.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions src/pip/_internal/req/req_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 11 additions & 7 deletions tests/functional/test_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+",
)
Copy link
Member Author

@sbidoul sbidoul Apr 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially thought this test failure was due to eggs not being detected with the importlib.metadata backend, but it turns out it's just a matter of the uninstallation order depending on the backend.

def test_uninstall_setuptools_develop_install(
script: PipTestEnvironment, data: TestData
) -> None:
Expand All @@ -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")


Expand Down