From a1ee6a22fdff29610f120cc96c667ac418ac7f86 Mon Sep 17 00:00:00 2001 From: Youngkwang Yang Date: Fri, 12 Jun 2026 17:11:22 +0900 Subject: [PATCH 1/4] Clarify warning for incompletely removed distributions --- src/pip/_internal/metadata/base.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/metadata/base.py b/src/pip/_internal/metadata/base.py index 8cc9cb1684..7cdfd73b66 100644 --- a/src/pip/_internal/metadata/base.py +++ b/src/pip/_internal/metadata/base.py @@ -616,11 +616,26 @@ def iter_all_distributions(self) -> Iterator[BaseDistribution]: flags=re.IGNORECASE, ) if not project_name_valid: - logger.warning( - "Ignoring invalid distribution %s (%s)", - dist.canonical_name, - dist.location, + # Check the directory name rather than the distribution name, + # since the pkg_resources backend normalizes the leading tilde + # to a dash. + info_location = dist.info_location + leftover_name = ( + pathlib.Path(info_location).name if info_location else "" ) + if leftover_name.startswith("~"): + logger.warning( + "Ignoring incompletely removed distribution %s (%s); " + "'~'-prefixed leftover directories are safe to delete", + leftover_name, + dist.location, + ) + else: + logger.warning( + "Ignoring invalid distribution %s (%s)", + dist.canonical_name, + dist.location, + ) continue yield dist From 2aef244af09681a4e7717c8c8bd547abbacef002 Mon Sep 17 00:00:00 2001 From: Youngkwang Yang Date: Fri, 12 Jun 2026 17:11:23 +0900 Subject: [PATCH 2/4] Add tests for incompletely removed distribution warning log --- tests/functional/test_freeze.py | 16 ++++++++++++ tests/unit/metadata/test_metadata.py | 26 +++++++++++++++++++ .../metadata/test_metadata_pkg_resources.py | 25 ++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/tests/functional/test_freeze.py b/tests/functional/test_freeze.py index dfc618896a..ff18c621c3 100644 --- a/tests/functional/test_freeze.py +++ b/tests/functional/test_freeze.py @@ -173,6 +173,15 @@ def fake_install(pkgname: str, dest: str) -> None: for pkgname in valid_pkgnames + invalid_pkgnames: fake_install(pkgname, os.fspath(script.site_packages_path)) + # Simulate a leftover of an interrupted uninstallation or upgrade, renamed + # to a tilde-prefixed name by pip's AdjacentTempDirectory. + leftover_dir = os.path.join( + os.fspath(script.site_packages_path), "~eftover-1.0.dist-info" + ) + os.mkdir(leftover_dir) + with open(os.path.join(leftover_dir, "METADATA"), "w") as metadata_file: + metadata_file.write("Metadata-Version: 1.0\nName: leftover\nVersion: 1.0\n") + result = script.pip("freeze", expect_stderr=True) # Check all valid names are in the output. @@ -185,11 +194,18 @@ def fake_install(pkgname: str, dest: str) -> None: for line in output_lines: output_name, _, _ = line.partition("=") assert canonicalize_name(output_name) not in canonical_invalid_names + assert "eftover" not in result.stdout # The invalid names should be logged. for name in canonical_invalid_names: assert f"Ignoring invalid distribution {name} (" in result.stderr + # The tilde-prefixed leftover should be reported as incompletely removed. + assert ( + "Ignoring incompletely removed distribution ~eftover-1.0.dist-info (" + in result.stderr + ) + @pytest.mark.git def test_freeze_editable_not_vcs(script: PipTestEnvironment) -> None: diff --git a/tests/unit/metadata/test_metadata.py b/tests/unit/metadata/test_metadata.py index 5e7e80fc74..905995dacc 100644 --- a/tests/unit/metadata/test_metadata.py +++ b/tests/unit/metadata/test_metadata.py @@ -147,3 +147,29 @@ def test_trailing_slash_directory_metadata( dist = get_directory_distribution(path) assert dist.raw_name == dist.canonical_name == "foo" assert dist.location == str(tmp_path) + + +def test_iter_all_distributions_warns_on_incomplete_removal( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + valid_info = tmp_path / "valid-1.0.dist-info" + valid_info.mkdir() + valid_info.joinpath("METADATA").write_text("Metadata-Version: 1.0\nName: valid\n") + + leftover_info = tmp_path / "~eftover-1.0.dist-info" + leftover_info.mkdir() + leftover_info.joinpath("METADATA").write_text( + "Metadata-Version: 1.0\nName: leftover\n" + ) + + env = get_environment([os.fspath(tmp_path)]) + with caplog.at_level(logging.WARNING): + dists = list(env.iter_all_distributions()) + + assert [dist.canonical_name for dist in dists] == ["valid"] + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert message.startswith( + "Ignoring incompletely removed distribution ~eftover-1.0.dist-info (" + ) + assert "safe to delete" in message diff --git a/tests/unit/metadata/test_metadata_pkg_resources.py b/tests/unit/metadata/test_metadata_pkg_resources.py index ccb0b7dcf0..ffaa485a71 100644 --- a/tests/unit/metadata/test_metadata_pkg_resources.py +++ b/tests/unit/metadata/test_metadata_pkg_resources.py @@ -1,5 +1,8 @@ import email.message import itertools +import logging +import os +from pathlib import Path from typing import cast from unittest import mock @@ -124,3 +127,25 @@ def test_wheel_metadata_throws_on_bad_unicode() -> None: with pytest.raises(UnsupportedWheel) as e: metadata.get_metadata("METADATA") assert "METADATA" in str(e.value) + + +def test_iter_all_distributions_warns_on_incomplete_removal( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + leftover_info = tmp_path / "~eftover-1.0.dist-info" + leftover_info.mkdir() + leftover_info.joinpath("METADATA").write_text( + "Metadata-Version: 1.0\nName: leftover\n" + ) + + env = Environment.from_paths([os.fspath(tmp_path)]) + with caplog.at_level(logging.WARNING): + dists = list(env.iter_all_distributions()) + + assert dists == [] + assert len(caplog.records) == 1 + message = caplog.records[0].getMessage() + assert message.startswith( + "Ignoring incompletely removed distribution ~eftover-1.0.dist-info (" + ) + assert "safe to delete" in message From 153a4455b24913aea8e078459bc62c485baa5e46 Mon Sep 17 00:00:00 2001 From: Youngkwang Yang Date: Fri, 12 Jun 2026 17:11:23 +0900 Subject: [PATCH 3/4] Add news file --- news/11849.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/11849.feature.rst diff --git a/news/11849.feature.rst b/news/11849.feature.rst new file mode 100644 index 0000000000..0c94c79f98 --- /dev/null +++ b/news/11849.feature.rst @@ -0,0 +1 @@ +Warn that ``~``-prefixed leftover directories are safe to delete. From 788e32442bec0cf0968bf7419e3bc86ebefbec76 Mon Sep 17 00:00:00 2001 From: Youngkwang Yang Date: Sun, 14 Jun 2026 19:45:24 +0900 Subject: [PATCH 4/4] Add version note and cleanup TODO to leftover-distribution comment --- src/pip/_internal/metadata/base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/metadata/base.py b/src/pip/_internal/metadata/base.py index 7cdfd73b66..9c3759a47b 100644 --- a/src/pip/_internal/metadata/base.py +++ b/src/pip/_internal/metadata/base.py @@ -617,8 +617,10 @@ def iter_all_distributions(self) -> Iterator[BaseDistribution]: ) if not project_name_valid: # Check the directory name rather than the distribution name, - # since the pkg_resources backend normalizes the leading tilde - # to a dash. + # since the pkg_resources (default below 3.11) backend normalizes + # the leading tilde to a dash. + # TODO: use dist.canonical_name for this check once + # pkg_resources support is dropped (#13317). info_location = dist.info_location leftover_name = ( pathlib.Path(info_location).name if info_location else ""