Skip to content

RFC: Add a new class to deal with some uninstallation issues#14097

Open
vfazio wants to merge 3 commits into
pypa:mainfrom
vfazio:vfazio-rebase-path-work
Open

RFC: Add a new class to deal with some uninstallation issues#14097
vfazio wants to merge 3 commits into
pypa:mainfrom
vfazio:vfazio-rebase-path-work

Conversation

@vfazio

@vfazio vfazio commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Related discussions:

This is a draft PR to have a discussion about deprecating compress_for_rename and possibly compress_for_output_listing.

The PathCompactor (working title... i'll openly admit that I was half-inspired by the garbage compactor in Star Wars ) added in this PR takes a different approach to identifying files compared to compress_for_rename.

Notables:

  • Directories in the path list are supported (so we can now add __pycache__ to the list of paths for removal)
  • Removal of namespace directories is supported (legacy and pep420)
  • A list of protected roots can be specified to prevent the attempted removal of bin/ and <purelib>/ directories when a package is the final package in an installation path (Likely to be the distribution's installed_location)
  • The class goes through great pains to not scan unnecessary directories, unlike compress_for_rename which will walk all directories potentially multiple times in an attempt to create wildcards.
  • The performance of determining the paths for removal is generally faster than the current compress_for_rename
  • Packages that place files at the root of <purelib>/ (yes, you setuptools and six and others) do not force an os.walk of all subdirectories within; path searches are jailed based on the paths received from the path list (RECORD)
  • The scan results are cached so rerunning the queries to get the paths for removal don't force another scan of the directories.

I've spent too many hours writing and testing and thinking about this since April/May out of guilt over the previous attempt to solve the __pycache__ issue so I'm mostly pushing this so I can get it out of my head and let it bake.

The risk here is that this is a major overhaul, so really does need time to be tested by others besides me and some thorough review by a maintainer. The behavior of compress_for_output_listing is shaky, but maybe it can be updated to work better... the namespace scenarios are the hardest cases here which is not accounted for in the old code.

I've tried to account for most production scenarios I can think of via the unit test matrix, but i'm sure i've missed some edge cases... I tried to be extremely thorough in these tests so as to:

  • inspire confidence in this solution
  • ensure there is a suite that is likely to light up like a Christmas tree if a code change introduces a regression (in an attempt to address the scenario where my changes did not flag a problem)

The calling interface is not finalized because I wanted input on how to handle it. The expectation is that a PathCompactor, or w/e we call it, is constructed once and is reused but never modified. The results of the public functions should be cacheable to avoid calculation overhead, but these are only called in a handful of places and pure string manipulation and set operations are relatively quick.

I've been comparing performance with the original implementation mostly via something like the below:

diff --git a/src/pip/_internal/req/req_uninstall.py b/src/pip/_internal/req/req_uninstall.py
index 9179c56e4..93c4abf0f 100644
--- a/src/pip/_internal/req/req_uninstall.py
+++ b/src/pip/_internal/req/req_uninstall.py
@@ -108,6 +108,19 @@ def compact(paths: Iterable[str]) -> set[str]:
     return short_paths
 
 
+def compress_for_rename2(
+    paths: Iterable[str], dist: BaseDistribution | None = None
+) -> set[str]:
+    roots = None
+    if dist and dist.installed_location:
+        roots = [dist.installed_location]
+    pc = PathCompactor(paths=paths, preserved_roots=roots)
+    pc._parse_paths()
+    pc._calculate_roots_and_owned_paths()
+    pc._process_roots()
+    return pc.compress_for_rename()
+
+
 def compress_for_rename(paths: Iterable[str]) -> set[str]:
     """Returns a set containing the paths that need to be renamed.
 
@@ -402,8 +415,16 @@ class UninstallPathSet:
         _display("Would remove:", will_remove)
         _display("Would not remove (might be manually added):", will_skip)
         _display("Would not remove (outside of prefix):", self._refuse)
+        import time
+
+        t0 = time.monotonic()
+        paths = compress_for_rename(self._paths)
+        print(time.monotonic() - t0)
+        t0 = time.monotonic()
+        paths = compress_for_rename2(self._paths)
+        print(time.monotonic() - t0)
         if verbose:
-            _display("Will actually move:", compress_for_rename(self._paths))
+            _display("Will actually move:", paths)
 
         return ask("Proceed (Y/n)? ", ("y", "n", "")) != "n"

Performance on a handful of packages:

(.venv) vfazio@Zephyrus:/tmp/tmp.PtBW31LFif$ pip uninstall scipy
Found existing installation: scipy 1.18.0
Uninstalling scipy-1.18.0:
  Would remove:
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy-1.18.0.dist-info/*
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy.libs/libgfortran-040039e1-0352e75f.so.5.0.0
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy.libs/libgfortran-83c28eba.so.5.0.0
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy.libs/libquadmath-2284e583.so.0.0.0
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy.libs/libquadmath-96973f99-934c22de.so.0.0.0
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy.libs/libscipy_openblas-5f890258.so
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/scipy/*
0.008996255000965903
0.0064402630014228635
Proceed (Y/n)? n
(.venv) vfazio@Zephyrus:/tmp/tmp.PtBW31LFif$ pip uninstall numpy
Found existing installation: numpy 2.5.0
Uninstalling numpy-2.5.0:
  Would remove:
    /tmp/tmp.PtBW31LFif/.venv/bin/f2py
    /tmp/tmp.PtBW31LFif/.venv/bin/numpy-config
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/numpy-2.5.0.dist-info/*
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/numpy.libs/libgfortran-83c28eba-b4027c22.so.5.0.0
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/numpy.libs/libquadmath-2284e583-a9307bba.so.0.0.0
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/numpy.libs/libscipy_openblas64_-017048f4.so
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/numpy/*
0.005786343001091154
0.004328621002059663
Proceed (Y/n)? n
(.venv) vfazio@Zephyrus:/tmp/tmp.PtBW31LFif$ pip uninstall pip
Found existing installation: pip 26.2.dev0
Uninstalling pip-26.2.dev0:
  Would remove:
    /tmp/tmp.PtBW31LFif/.venv/bin/pip
    /tmp/tmp.PtBW31LFif/.venv/bin/pip3
    /tmp/tmp.PtBW31LFif/.venv/bin/pip3.12
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/pip-26.2.dev0.dist-info/*
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/pip/*
0.004044616001920076
0.0029411569994408637
Proceed (Y/n)? n

(.venv) vfazio@Zephyrus:/tmp/tmp.PtBW31LFif$ pip uninstall setuptools
Found existing installation: setuptools 82.0.1
Uninstalling setuptools-82.0.1:
  Would remove:
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/_distutils_hack/*
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/distutils-precedence.pth
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/setuptools-82.0.1.dist-info/*
    /tmp/tmp.PtBW31LFif/.venv/lib/python3.12/site-packages/setuptools/*
0.024457779996737372
0.0025081160019908566

PR Checklist:

  • I agree to follow the PSF Code of Conduct.
  • I have read and have followed the CONTRIBUTING.md file.
  • I have added a news file fragment (or this PR does not need one).
  • I have read and followed the AI_POLICY.md file, and if any AI tools were used, I have disclosed it below.

@vfazio

vfazio commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

This will likely fail on windows because i didn't test it there and i'm sure i got the path stuff wrong somehow

@vfazio

vfazio commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

With apologies @notatallshaw

@vfazio

vfazio commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Since this is just an RFC and probably not going to be merged as-is, I'm marking this as ready for review

@vfazio
vfazio marked this pull request as ready for review June 25, 2026 18:36
@vfazio
vfazio force-pushed the vfazio-rebase-path-work branch from 0829f13 to 7335f6b Compare July 2, 2026 14:29
@ichard26 ichard26 added the skip PR template check Silence the PR template check in CI label Jul 5, 2026
vfazio added 3 commits July 11, 2026 18:46
Note that the skipped directories were added so that file skipped for a
namespace package could be wildcarded for display and not give users the
expectation that everything would be removed.
@vfazio
vfazio force-pushed the vfazio-rebase-path-work branch from 7335f6b to d1bb46d Compare July 11, 2026 23:56
@vfazio

vfazio commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

updated the PR description to reflect the new PR template and rebasing

@ichard26

ichard26 commented Jul 12, 2026

Copy link
Copy Markdown
Member

Just to let you know, I'm unlikely to review this since I was not involved when work was originally being done here. This is a massive PR and I have larger priorities since we are approaching release 26.2.

The other maintainers who were previously involved here would be a better bet for reviews. Thanks!

Also, we recently merged #14108 which probably changes your performance numbers.

edit: improving phrasing since it was too brash originally

@vfazio

vfazio commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Yea, no offense taken. It is large though a lot of it is comments and this is largely meant for discussion. I did try to address a few long standing issues but they've been around for so long I can understand if this overhaul is seen as not worth it.

For now this doesn't change any actual code in the uninstall path and is a side-by-side implementation.

The performance numbers should not be affected by any changes to compact as that is run on the results of the uninstall instructions. The resultset from the new code should already be compacted except for the compress_for_output_listing output. I can run the numbers again just in case.

@vfazio

vfazio commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

rebasing and retesting

(.venv) vfazio@Zephyrus:~/development/pip$ git log --oneline -5
60fd219e4 (HEAD -> vfazio-rebase-path-work) add trivial NEWS for now
2da88a3b2 add first unittest
84ffae5b9 add PathCompactor
a951a1fda (origin/main, origin/HEAD) Add a test for Requires-Python skip reasons without debug logging (#14170)
c8b4819f2 Merge pull request #14164 from notatallshaw/update-security-policy

diff --git a/src/pip/_internal/req/req_uninstall.py b/src/pip/_internal/req/req_uninstall.py
index 11e9aae2b..7feea0ce2 100644
--- a/src/pip/_internal/req/req_uninstall.py
+++ b/src/pip/_internal/req/req_uninstall.py
@@ -113,6 +113,30 @@ def compact(paths: Iterable[str]) -> set[str]:
     return short_paths
 
 
+def compress_for_rename2(
+    paths: Iterable[str], dist: BaseDistribution | None = None
+) -> set[str]:
+    roots = None
+    if dist and dist.installed_location:
+        roots = [dist.installed_location]
+    pc = PathCompactor(paths=paths, preserved_roots=roots)
+    pc._parse_paths()
+    pc._calculate_roots_and_owned_paths()
+    pc._process_roots()
+    return pc.compress_for_rename()
+
+def compress_for_output_listing2(
+    paths: Iterable[str], dist: BaseDistribution | None = None
+) -> tuple[set[str], set[str]]:
+    roots = None
+    if dist and dist.installed_location:
+        roots = [dist.installed_location]
+    pc = PathCompactor(paths=paths, preserved_roots=roots)
+    pc._parse_paths()
+    pc._calculate_roots_and_owned_paths()
+    pc._process_roots()
+    return pc.compress_for_output_listing()
+
 def compress_for_rename(paths: Iterable[str]) -> set[str]:
     """Returns a set containing the paths that need to be renamed.
 
@@ -396,8 +420,14 @@ class UninstallPathSet:
                 for path in sorted(compact(paths)):
                     logger.info(path)
 
+        import time
         if not verbose:
+            t0 = time.monotonic()
             will_remove, will_skip = compress_for_output_listing(self._paths)
+            print(time.monotonic() - t0)
+            t0 = time.monotonic()
+            will_remove, will_skip = compress_for_output_listing2(self._paths)
+            print(time.monotonic() - t0)
         else:
             # In verbose mode, display all the files that are going to be
             # deleted.
@@ -407,8 +437,15 @@ class UninstallPathSet:
         _display("Would remove:", will_remove)
         _display("Would not remove (might be manually added):", will_skip)
         _display("Would not remove (outside of prefix):", self._refuse)
+
+        t0 = time.monotonic()
+        paths = compress_for_rename(self._paths)
+        print(time.monotonic() - t0)
+        t0 = time.monotonic()
+        paths = compress_for_rename2(self._paths)
+        print(time.monotonic() - t0)
         if verbose:
-            _display("Will actually move:", compress_for_rename(self._paths))
+            _display("Will actually move:", paths)
 
         return ask("Proceed (Y/n)? ", ("y", "n", "")) != "n"

Crude timings for differences between compress_for_output_listing and compress_for_rename

Proceed (Y/n)? (.venv) vfazio@Zephyrus:/tmp/tmp.NVCIQzuMFj$ echo "n" | pip uninstall scipy
Found existing installation: scipy 1.18.0
Uninstalling scipy-1.18.0:
0.012108484999771463
0.006995064999500755
  Would remove:
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/scipy-1.18.0.dist-info/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/scipy.libs/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/scipy/*
0.008679952003149083
0.0068972929984738585
Proceed (Y/n)? (.venv) vfazio@Zephyrus:/tmp/tmp.NVCIQzuMFj$ echo "n" | pip uninstall numpy
Found existing installation: numpy 2.5.1
Uninstalling numpy-2.5.1:
0.007754874997772276
0.004328040999098448
  Would remove:
    /tmp/tmp.NVCIQzuMFj/.venv/bin/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/numpy-2.5.1.dist-info/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/numpy.libs/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/numpy/*
  Would not remove (might be manually added):
    /tmp/tmp.NVCIQzuMFj/.venv/bin/Activate.ps1
    /tmp/tmp.NVCIQzuMFj/.venv/bin/activate
    /tmp/tmp.NVCIQzuMFj/.venv/bin/activate.csh
    /tmp/tmp.NVCIQzuMFj/.venv/bin/activate.fish
    /tmp/tmp.NVCIQzuMFj/.venv/bin/pip
    /tmp/tmp.NVCIQzuMFj/.venv/bin/pip3
    /tmp/tmp.NVCIQzuMFj/.venv/bin/pip3.12
    /tmp/tmp.NVCIQzuMFj/.venv/bin/python
    /tmp/tmp.NVCIQzuMFj/.venv/bin/python3
    /tmp/tmp.NVCIQzuMFj/.venv/bin/python3.12
0.005409224999311846
0.003931221002858365
(.venv) vfazio@Zephyrus:/tmp/tmp.NVCIQzuMFj$ echo "n" | pip uninstall setuptools
Found existing installation: setuptools 83.0.0
Uninstalling setuptools-83.0.0:
0.021986175001075026
0.002721512999414699
  Would remove:
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/*
  Would not remove (might be manually added):
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/numpy-2.5.1.dist-info/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/numpy.libs/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/numpy/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/pip-26.2.dev0.dist-info/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/pip/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/scipy-1.18.0.dist-info/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/scipy.libs/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/scipy/*
0.04054339399954188
0.0025923989996954333
Proceed (Y/n)? (.venv) vfazio@Zephyrus:/tmp/tmp.NVCIQzuMFj$ echo "n" | pip uninstall pip
Found existing installation: pip 26.2.dev0
Uninstalling pip-26.2.dev0:
0.004927332996885525
0.0034582519983814564
  Would remove:
    /tmp/tmp.NVCIQzuMFj/.venv/bin/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/pip-26.2.dev0.dist-info/*
    /tmp/tmp.NVCIQzuMFj/.venv/lib/python3.12/site-packages/pip/*
  Would not remove (might be manually added):
    /tmp/tmp.NVCIQzuMFj/.venv/bin/Activate.ps1
    /tmp/tmp.NVCIQzuMFj/.venv/bin/activate
    /tmp/tmp.NVCIQzuMFj/.venv/bin/activate.csh
    /tmp/tmp.NVCIQzuMFj/.venv/bin/activate.fish
    /tmp/tmp.NVCIQzuMFj/.venv/bin/f2py
    /tmp/tmp.NVCIQzuMFj/.venv/bin/numpy-config
    /tmp/tmp.NVCIQzuMFj/.venv/bin/python
    /tmp/tmp.NVCIQzuMFj/.venv/bin/python3
    /tmp/tmp.NVCIQzuMFj/.venv/bin/python3.12
0.004012011999293463
0.0029190019995439798

Second number is the new code.

Perf doesn't look impacted by recent changes on my current setup. Its probably important to call out that these are not scientific tests and may be subject to things like filesystem cache hits.

It's also important to note that with the new class, the search results can be cached so the cost of the search could only happen once. The only tax is calculating the results from that search which shouldn't be overly expensive.

In the current code we have the following search hits:

If not verbose:

  • compress_for_output_listing
  • compress_for_rename x2

If verbose:

  • compress_for_rename x2

Both paths call compress_for_rename as part of .remove.

So, we could save one, maybe two, directory scans at minimum, though like i mentioned in the description, the current algorithm calls os.walk for each subdirectory in an attempt to mark all files as removable so we could save many more scans due to orphaned files (.pyc, my old friend) interfering with directory removal. Probably has a bigger impact on network directories

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided skip PR template check Silence the PR template check in CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants