Repository: pypa/pip

Issue:
pip uninstall does not remove symlinks created by flit install --symlink
**Environment**

* pip version: 19.2
* Python version: 3
* OS: linux

**Description**

`pip uninstall` does not remove symlinks created in a virtualenv by `flit install --symlink`,
because it thinks they are outside the virtualenv prefix.

**Expected behavior**

Such symlinks should be removed by pip uninstall

**How to Reproduce**

```console
$ python3 -m venv venv
$ source venv/bin/activate
$ pip install -U pip
$ git clone https://github.com/pypa/pep517.git  # example flit project
$ cd pep517
$ pip install flit
$ flit install --symlink
$ pip uninstall pep517
Uninstalling pep517-0.6.0:
  Would remove:
    /home/my/.virtualenvs/tempenv-2d6243995df5/lib/python3.7/site-packages/pep517-0.6.0.dist-info/*
  Would not remove (outside of prefix):
    /home/my/.virtualenvs/tempenv-2d6243995df5/lib/python3.7/site-packages/pep517
Proceed (y/n)? 
```

**Analysis**

I could find work on this issue before in #2552 by @takluyver and #3154 by @qwcode. I did not find what commit caused the problem to surface again.



Discussion:
Do you know if there are any versions in which this does work? I tried the versions containing those fixes but did not have any that would remove the file (of 6.0.0, 6.1.0, 7.0.0, 8.0.0, 9.0.0, 10.0.0, 18.1).
After posting this, I confirmed #3154 does not work for the use case above.

A fix seems straightforward on the current code base ([diff](https://github.com/pypa/pip/compare/pypa:97d9c6d...sbidoul:4b1bc73)). But it reveals another issue: when uninstalling such symlinks, pip ends up modifying the permissions on the target directory to 0200...

Resolving patch:
diff --git a/src/pip/_internal/req/req_uninstall.py b/src/pip/_internal/req/req_uninstall.py
--- a/src/pip/_internal/req/req_uninstall.py
+++ b/src/pip/_internal/req/req_uninstall.py
@@ -267,14 +267,16 @@ def _get_file_stash(self, path):
     def stash(self, path):
         # type: (str) -> str
         """Stashes the directory or file and returns its new location.
+        Handle symlinks as files to avoid modifying the symlink targets.
         """
-        if os.path.isdir(path):
+        path_is_dir = os.path.isdir(path) and not os.path.islink(path)
+        if path_is_dir:
             new_path = self._get_directory_stash(path)
         else:
             new_path = self._get_file_stash(path)
 
         self._moves.append((path, new_path))
-        if os.path.isdir(path) and os.path.isdir(new_path):
+        if (path_is_dir and os.path.isdir(new_path)):
             # If we're moving a directory, we need to
             # remove the destination first or else it will be
             # moved to inside the existing directory.
@@ -301,7 +303,7 @@ def rollback(self):
         for new_path, path in self._moves:
             try:
                 logger.debug('Replacing %s from %s', new_path, path)
-                if os.path.isfile(new_path):
+                if os.path.isfile(new_path) or os.path.islink(new_path):
                     os.unlink(new_path)
                 elif os.path.isdir(new_path):
                     rmtree(new_path)
diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py
--- a/src/pip/_internal/utils/misc.py
+++ b/src/pip/_internal/utils/misc.py
@@ -427,10 +427,12 @@ def is_local(path):
 
     If we're not in a virtualenv, all paths are considered "local."
 
+    Caution: this function assumes the head of path has been normalized
+    with normalize_path.
     """
     if not running_under_virtualenv():
         return True
-    return normalize_path(path).startswith(normalize_path(sys.prefix))
+    return path.startswith(normalize_path(sys.prefix))
 
 
 def dist_is_local(dist):
@@ -450,8 +452,7 @@ def dist_in_usersite(dist):
     """
     Return True if given Distribution is installed in user site.
     """
-    norm_path = normalize_path(dist_location(dist))
-    return norm_path.startswith(normalize_path(user_site))
+    return dist_location(dist).startswith(normalize_path(user_site))
 
 
 def dist_in_site_packages(dist):
@@ -460,9 +461,7 @@ def dist_in_site_packages(dist):
     Return True if given Distribution is installed in
     sysconfig.get_python_lib().
     """
-    return normalize_path(
-        dist_location(dist)
-    ).startswith(normalize_path(site_packages))
+    return dist_location(dist).startswith(normalize_path(site_packages))
 
 
 def dist_is_editable(dist):
@@ -593,11 +592,12 @@ def dist_location(dist):
     packages, where dist.location is the source code location, and we
     want to know where the egg-link file is.
 
+    The returned location is normalized (in particular, with symlinks removed).
     """
     egg_link = egg_link_path(dist)
     if egg_link:
-        return egg_link
-    return dist.location
+        return normalize_path(egg_link)
+    return normalize_path(dist.location)
 
 
 def current_umask():


Test patch:
diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py
--- a/tests/functional/test_uninstall.py
+++ b/tests/functional/test_uninstall.py
@@ -458,6 +458,28 @@ def test_uninstall_wheel(script, data):
     assert_all_changes(result, result2, [])
 
 
+@pytest.mark.skipif("sys.platform == 'win32'")
+def test_uninstall_with_symlink(script, data, tmpdir):
+    """
+    Test uninstalling a wheel, with an additional symlink
+    https://github.com/pypa/pip/issues/6892
+    """
+    package = data.packages.joinpath("simple.dist-0.1-py2.py3-none-any.whl")
+    script.pip('install', package, '--no-index')
+    symlink_target = tmpdir / "target"
+    symlink_target.mkdir()
+    symlink_source = script.site_packages / "symlink"
+    (script.base_path / symlink_source).symlink_to(symlink_target)
+    st_mode = symlink_target.stat().st_mode
+    distinfo_path = script.site_packages_path / 'simple.dist-0.1.dist-info'
+    record_path = distinfo_path / 'RECORD'
+    with open(record_path, "a") as f:
+        f.write("symlink,,\n")
+    uninstall_result = script.pip('uninstall', 'simple.dist', '-y')
+    assert symlink_source in uninstall_result.files_deleted
+    assert symlink_target.stat().st_mode == st_mode
+
+
 def test_uninstall_setuptools_develop_install(script, data):
     """Try uninstall after setup.py develop followed of setup.py install"""
     pkg_path = data.packages.joinpath("FSPkg")
diff --git a/tests/lib/path.py b/tests/lib/path.py
--- a/tests/lib/path.py
+++ b/tests/lib/path.py
@@ -208,4 +208,10 @@ def touch(self):
             path = fp.fileno() if os.utime in supports_fd else self
             os.utime(path, None)  # times is not optional on Python 2.7
 
+    def symlink_to(self, target):
+        os.symlink(target, self)
+
+    def stat(self):
+        return os.stat(self)
+
 curdir = Path(os.path.curdir)
diff --git a/tests/unit/test_req_uninstall.py b/tests/unit/test_req_uninstall.py
--- a/tests/unit/test_req_uninstall.py
+++ b/tests/unit/test_req_uninstall.py
@@ -183,7 +183,7 @@ def test_add_symlink(self, tmpdir, monkeypatch):
 
     def test_compact_shorter_path(self, monkeypatch):
         monkeypatch.setattr(pip._internal.req.req_uninstall, 'is_local',
-                            lambda p: True)
+                            mock_is_local)
         monkeypatch.setattr('os.path.exists', lambda p: True)
         # This deals with nt/posix path differences
         short_path = os.path.normcase(os.path.abspath(
@@ -196,7 +196,7 @@ def test_compact_shorter_path(self, monkeypatch):
     @pytest.mark.skipif("sys.platform == 'win32'")
     def test_detect_symlink_dirs(self, monkeypatch, tmpdir):
         monkeypatch.setattr(pip._internal.req.req_uninstall, 'is_local',
-                            lambda p: True)
+                            mock_is_local)
 
         # construct 2 paths:
         #  tmpdir/dir/file
@@ -306,3 +306,67 @@ def test_rollback(self, tmpdir):
         for old_path, new_path in stashed_paths:
             assert os.path.exists(old_path)
             assert not os.path.exists(new_path)
+
+    @pytest.mark.skipif("sys.platform == 'win32'")
+    def test_commit_symlinks(self, tmpdir):
+        adir = tmpdir / "dir"
+        adir.mkdir()
+        dirlink = tmpdir / "dirlink"
+        dirlink.symlink_to(adir)
+        afile = tmpdir / "file"
+        afile.write_text("...")
+        filelink = tmpdir / "filelink"
+        filelink.symlink_to(afile)
+
+        pathset = StashedUninstallPathSet()
+        stashed_paths = []
+        stashed_paths.append(pathset.stash(dirlink))
+        stashed_paths.append(pathset.stash(filelink))
+        for stashed_path in stashed_paths:
+            assert os.path.lexists(stashed_path)
+        assert not os.path.exists(dirlink)
+        assert not os.path.exists(filelink)
+
+        pathset.commit()
+
+        # stash removed, links removed
+        for stashed_path in stashed_paths:
+            assert not os.path.lexists(stashed_path)
+        assert not os.path.lexists(dirlink) and not os.path.isdir(dirlink)
+        assert not os.path.lexists(filelink) and not os.path.isfile(filelink)
+
+        # link targets untouched
+        assert os.path.isdir(adir)
+        assert os.path.isfile(afile)
+
+    @pytest.mark.skipif("sys.platform == 'win32'")
+    def test_rollback_symlinks(self, tmpdir):
+        adir = tmpdir / "dir"
+        adir.mkdir()
+        dirlink = tmpdir / "dirlink"
+        dirlink.symlink_to(adir)
+        afile = tmpdir / "file"
+        afile.write_text("...")
+        filelink = tmpdir / "filelink"
+        filelink.symlink_to(afile)
+
+        pathset = StashedUninstallPathSet()
+        stashed_paths = []
+        stashed_paths.append(pathset.stash(dirlink))
+        stashed_paths.append(pathset.stash(filelink))
+        for stashed_path in stashed_paths:
+            assert os.path.lexists(stashed_path)
+        assert not os.path.lexists(dirlink)
+        assert not os.path.lexists(filelink)
+
+        pathset.rollback()
+
+        # stash removed, links restored
+        for stashed_path in stashed_paths:
+            assert not os.path.lexists(stashed_path)
+        assert os.path.lexists(dirlink) and os.path.isdir(dirlink)
+        assert os.path.lexists(filelink) and os.path.isfile(filelink)
+
+        # link targets untouched
+        assert os.path.isdir(adir)
+        assert os.path.isfile(afile)