Skip to content

Commit 55f1251

Browse files
authored
Merge pull request #11868 from DefaultRyan/normalize-path-cached
cache normalize_path in req_uninstall and is_local
2 parents d0c50a0 + 5294e34 commit 55f1251

File tree

3 files changed

+45
-11
lines changed

3 files changed

+45
-11
lines changed

news/11889.bugfix.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
The ``uninstall`` and ``install --force-reinstall`` commands no longer call
2+
``normalize_path()`` repeatedly on the same paths. Instead, these results are
3+
cached for the duration of an uninstall operation, resulting in improved
4+
performance, particularly on Windows.

src/pip/_internal/req/req_uninstall.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
from pip._internal.utils.compat import WINDOWS
1212
from pip._internal.utils.egg_link import egg_link_path_from_location
1313
from pip._internal.utils.logging import getLogger, indent_log
14-
from pip._internal.utils.misc import ask, is_local, normalize_path, renames, rmtree
14+
from pip._internal.utils.misc import ask, normalize_path, renames, rmtree
1515
from pip._internal.utils.temp_dir import AdjacentTempDirectory, TempDirectory
16+
from pip._internal.utils.virtualenv import running_under_virtualenv
1617

1718
logger = getLogger(__name__)
1819

@@ -312,21 +313,28 @@ def __init__(self, dist: BaseDistribution) -> None:
312313
self._pth: Dict[str, UninstallPthEntries] = {}
313314
self._dist = dist
314315
self._moved_paths = StashedUninstallPathSet()
316+
# Create local cache of normalize_path results. Creating an UninstallPathSet
317+
# can result in hundreds/thousands of redundant calls to normalize_path with
318+
# the same args, which hurts performance.
319+
self._normalize_path_cached = functools.lru_cache()(normalize_path)
315320

316321
def _permitted(self, path: str) -> bool:
317322
"""
318323
Return True if the given path is one we are permitted to
319324
remove/modify, False otherwise.
320325
321326
"""
322-
return is_local(path)
327+
# aka is_local, but caching normalized sys.prefix
328+
if not running_under_virtualenv():
329+
return True
330+
return path.startswith(self._normalize_path_cached(sys.prefix))
323331

324332
def add(self, path: str) -> None:
325333
head, tail = os.path.split(path)
326334

327335
# we normalize the head to resolve parent directory symlinks, but not
328336
# the tail, since we only want to uninstall symlinks, not their targets
329-
path = os.path.join(normalize_path(head), os.path.normcase(tail))
337+
path = os.path.join(self._normalize_path_cached(head), os.path.normcase(tail))
330338

331339
if not os.path.exists(path):
332340
return
@@ -341,7 +349,7 @@ def add(self, path: str) -> None:
341349
self.add(cache_from_source(path))
342350

343351
def add_pth(self, pth_file: str, entry: str) -> None:
344-
pth_file = normalize_path(pth_file)
352+
pth_file = self._normalize_path_cached(pth_file)
345353
if self._permitted(pth_file):
346354
if pth_file not in self._pth:
347355
self._pth[pth_file] = UninstallPthEntries(pth_file)
@@ -531,7 +539,9 @@ def from_dist(cls, dist: BaseDistribution) -> "UninstallPathSet":
531539
# above, so this only covers the setuptools-style editable.
532540
with open(develop_egg_link) as fh:
533541
link_pointer = os.path.normcase(fh.readline().strip())
534-
normalized_link_pointer = normalize_path(link_pointer)
542+
normalized_link_pointer = paths_to_remove._normalize_path_cached(
543+
link_pointer
544+
)
535545
assert os.path.samefile(
536546
normalized_link_pointer, normalized_dist_location
537547
), (

tests/unit/test_req_uninstall.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
# Pretend all files are local, so UninstallPathSet accepts files in the tmpdir,
2323
# outside the virtualenv
24-
def mock_is_local(path: str) -> bool:
24+
def mock_permitted(ups: UninstallPathSet, path: str) -> bool:
2525
return True
2626

2727

@@ -129,7 +129,11 @@ def in_tmpdir(paths: List[str]) -> List[str]:
129129

130130
class TestUninstallPathSet:
131131
def test_add(self, tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
132-
monkeypatch.setattr(pip._internal.req.req_uninstall, "is_local", mock_is_local)
132+
monkeypatch.setattr(
133+
pip._internal.req.req_uninstall.UninstallPathSet,
134+
"_permitted",
135+
mock_permitted,
136+
)
133137
# Fix case for windows tests
134138
file_extant = os.path.normcase(os.path.join(tmpdir, "foo"))
135139
file_nonexistent = os.path.normcase(os.path.join(tmpdir, "nonexistent"))
@@ -145,7 +149,11 @@ def test_add(self, tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
145149
assert ups._paths == {file_extant}
146150

147151
def test_add_pth(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
148-
monkeypatch.setattr(pip._internal.req.req_uninstall, "is_local", mock_is_local)
152+
monkeypatch.setattr(
153+
pip._internal.req.req_uninstall.UninstallPathSet,
154+
"_permitted",
155+
mock_permitted,
156+
)
149157
# Fix case for windows tests
150158
tmpdir = os.path.normcase(tmp_path)
151159
on_windows = sys.platform == "win32"
@@ -175,7 +183,11 @@ def test_add_pth(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
175183

176184
@pytest.mark.skipif("sys.platform == 'win32'")
177185
def test_add_symlink(self, tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
178-
monkeypatch.setattr(pip._internal.req.req_uninstall, "is_local", mock_is_local)
186+
monkeypatch.setattr(
187+
pip._internal.req.req_uninstall.UninstallPathSet,
188+
"_permitted",
189+
mock_permitted,
190+
)
179191
f = os.path.join(tmpdir, "foo")
180192
with open(f, "w"):
181193
pass
@@ -187,7 +199,11 @@ def test_add_symlink(self, tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> Non
187199
assert ups._paths == {foo_link}
188200

189201
def test_compact_shorter_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
190-
monkeypatch.setattr(pip._internal.req.req_uninstall, "is_local", mock_is_local)
202+
monkeypatch.setattr(
203+
pip._internal.req.req_uninstall.UninstallPathSet,
204+
"_permitted",
205+
mock_permitted,
206+
)
191207
monkeypatch.setattr("os.path.exists", lambda p: True)
192208
# This deals with nt/posix path differences
193209
short_path = os.path.normcase(
@@ -202,7 +218,11 @@ def test_compact_shorter_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
202218
def test_detect_symlink_dirs(
203219
self, monkeypatch: pytest.MonkeyPatch, tmpdir: Path
204220
) -> None:
205-
monkeypatch.setattr(pip._internal.req.req_uninstall, "is_local", mock_is_local)
221+
monkeypatch.setattr(
222+
pip._internal.req.req_uninstall.UninstallPathSet,
223+
"_permitted",
224+
mock_permitted,
225+
)
206226

207227
# construct 2 paths:
208228
# tmpdir/dir/file

0 commit comments

Comments
 (0)