Repository: pypa/virtualenv

Issue:
20.0.3: pypy could be relative symlinks for executables?
this is a very minor difference with previous versions, but is making it difficult to properly fix [virtualenv-tools3](https://github.com/Yelp/virtualenv-tools/pull/22)

```console
$ virtualenv --version
16.7.9
$ virtualenv venv -p pypy
Running virtualenv with interpreter /home/asottile/bin/pypy
Already using interpreter /home/asottile/bin/pypy
New pypy executable in /tmp/venv/bin/pypy
Installing setuptools, pip, wheel...
done.
$ ls -al ./venv/bin/
total 64
drwxr-xr-x 2 asottile asottile 4096 Feb 12 09:36 .
drwxr-xr-x 8 asottile asottile 4096 Feb 12 09:36 ..
-rw-r--r-- 1 asottile asottile 2198 Feb 12 09:36 activate
-rw-r--r-- 1 asottile asottile 1417 Feb 12 09:36 activate.csh
-rw-r--r-- 1 asottile asottile 3082 Feb 12 09:36 activate.fish
-rw-r--r-- 1 asottile asottile 1751 Feb 12 09:36 activate.ps1
-rw-r--r-- 1 asottile asottile 1517 Feb 12 09:36 activate_this.py
-rwxr-xr-x 1 asottile asottile  232 Feb 12 09:36 easy_install
-rwxr-xr-x 1 asottile asottile  232 Feb 12 09:36 easy_install-2.7
lrwxrwxrwx 1 asottile asottile   58 Feb 12 09:36 libpypy-c.so -> /home/asottile/opt/pypy2.7-v7.3.0-linux64/bin/libpypy-c.so
-rwxr-xr-x 1 asottile asottile  223 Feb 12 09:36 pip
-rwxr-xr-x 1 asottile asottile  223 Feb 12 09:36 pip2
-rwxr-xr-x 1 asottile asottile  223 Feb 12 09:36 pip2.7
-rwxr-xr-x 1 asottile asottile 6296 Feb 12 09:36 pypy
lrwxrwxrwx 1 asottile asottile    4 Feb 12 09:36 python -> pypy
lrwxrwxrwx 1 asottile asottile    4 Feb 12 09:36 python2 -> pypy
lrwxrwxrwx 1 asottile asottile    4 Feb 12 09:36 python2.7 -> pypy
-rwxr-xr-x 1 asottile asottile 2327 Feb 12 09:36 python-config
-rwxr-xr-x 1 asottile asottile  210 Feb 12 09:36 wheel
```

(notice `python` / `python2` / `python2.7` are relative symlinks)

if we continue this and make a 20.x virtualenv from that:

```console
$ ./venv/bin/pip install virtualenv -qq
$ ./venv/bin/virtualenv --version
virtualenv 20.0.3 from /tmp/venv/site-packages/virtualenv/__init__.pyc
$ ./venv/bin/virtualenv venvpp
created virtual environment in 202ms PyPy2Posix(dest=/tmp/venvpp, clear=False, global=False) with seeder FromAppData pip=latest setuptools=latest wheel=latest app_data_dir=/home/asottile/.local/share/virtualenv/seed-v1 via=copy
$ ls -al venvpp/bin/
total 72
drwxr-xr-x 2 asottile asottile 4096 Feb 12 09:37 .
drwxr-xr-x 6 asottile asottile 4096 Feb 12 09:37 ..
-rw-r--r-- 1 asottile asottile 2200 Feb 12 09:37 activate
-rw-r--r-- 1 asottile asottile 1419 Feb 12 09:37 activate.csh
-rw-r--r-- 1 asottile asottile 3050 Feb 12 09:37 activate.fish
-rw-r--r-- 1 asottile asottile 1751 Feb 12 09:37 activate.ps1
-rw-r--r-- 1 asottile asottile 1185 Feb 12 09:37 activate_this.py
-rwxr-xr-x 1 asottile asottile  234 Feb 12 09:37 easy_install
-rwxr-xr-x 1 asottile asottile  234 Feb 12 09:37 easy_install2
-rwxr-xr-x 1 asottile asottile  234 Feb 12 09:37 easy_install-2.7
lrwxrwxrwx 1 asottile asottile   58 Feb 12 09:37 libpypy-c.so -> /home/asottile/opt/pypy2.7-v7.3.0-linux64/bin/libpypy-c.so
-rwxr-xr-x 1 asottile asottile  225 Feb 12 09:37 pip
-rwxr-xr-x 1 asottile asottile  225 Feb 12 09:37 pip2
-rwxr-xr-x 1 asottile asottile  225 Feb 12 09:37 pip-2.7
-rwxr-xr-x 1 asottile asottile 6296 Feb 12 09:37 pypy
lrwxrwxrwx 1 asottile asottile   20 Feb 12 09:37 python -> /tmp/venvpp/bin/pypy
lrwxrwxrwx 1 asottile asottile   20 Feb 12 09:37 python2 -> /tmp/venvpp/bin/pypy
-rwxr-xr-x 1 asottile asottile  212 Feb 12 09:37 wheel
-rwxr-xr-x 1 asottile asottile  212 Feb 12 09:37 wheel2
-rwxr-xr-x 1 asottile asottile  212 Feb 12 09:37 wheel-2.7
```

and here they are symlinks but absolute (making it difficult to relocate)

also it seems `python2.7` is missing, but that's separate

I'd be happy to help write a patch -- though I need to re-figure-out the new code layout :)


Discussion:
Sure, take a look here https://github.com/pypa/virtualenv/blob/master/src/virtualenv/create/via_global_ref/builtin/ref.py#L97-L122

Resolving patch:
diff --git a/src/virtualenv/create/via_global_ref/builtin/ref.py b/src/virtualenv/create/via_global_ref/builtin/ref.py
--- a/src/virtualenv/create/via_global_ref/builtin/ref.py
+++ b/src/virtualenv/create/via_global_ref/builtin/ref.py
@@ -7,8 +7,8 @@
 
 from six import add_metaclass
 
-from virtualenv.info import PY3, fs_is_case_sensitive, fs_supports_symlink
-from virtualenv.util.path import copy, link, make_exe, symlink
+from virtualenv.info import fs_is_case_sensitive, fs_supports_symlink
+from virtualenv.util.path import copy, make_exe, symlink
 from virtualenv.util.six import ensure_text
 
 
@@ -95,9 +95,6 @@ def run(self, creator, symlinks):
             method(self.src, dst)
 
 
-alias_via = link if PY3 else (symlink if PathRef.FS_SUPPORTS_SYMLINK else copy)
-
-
 class ExePathRefToDest(PathRefToDest, ExePathRef):
     def __init__(self, src, targets, dest, must_copy=False):
         ExePathRef.__init__(self, src)
@@ -119,5 +116,8 @@ def run(self, creator, symlinks):
             link_file = bin_dir / extra
             if link_file.exists():
                 link_file.unlink()
-            alias_via(dest, link_file)
+            if symlinks:
+                link_file.symlink_to(self.base)
+            else:
+                copy(self.src, link_file)
             make_exe(link_file)
diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py
--- a/src/virtualenv/util/path/__init__.py
+++ b/src/virtualenv/util/path/__init__.py
@@ -2,11 +2,10 @@
 
 from ._pathlib import Path
 from ._permission import make_exe
-from ._sync import copy, copytree, ensure_dir, link, symlink
+from ._sync import copy, copytree, ensure_dir, symlink
 
 __all__ = (
     "ensure_dir",
-    "link",
     "symlink",
     "copy",
     "copytree",
diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py
--- a/src/virtualenv/util/path/_sync.py
+++ b/src/virtualenv/util/path/_sync.py
@@ -4,14 +4,11 @@
 import os
 import shutil
 
-from six import PY2, PY3
+from six import PY2
 
 from virtualenv.info import IS_CPYTHON, IS_WIN
 from virtualenv.util.six import ensure_text
 
-if PY3:
-    from os import link as os_link
-
 if PY2 and IS_CPYTHON and IS_WIN:  # CPython2 on Windows supports unicode paths if passed as unicode
     norm = lambda src: ensure_text(str(src))  # noqa
 else:
@@ -62,12 +59,6 @@ def copytree(src, dest):
             shutil.copy(src_f, dest_f)
 
 
-def link(src, dest):
-    ensure_safe_to_do(src, dest)
-    logging.debug("hard link %s", _Debug(src, dest.name))
-    os_link(norm(src), norm(dest))
-
-
 class _Debug(object):
     def __init__(self, src, dest):
         self.src = src
@@ -83,8 +74,6 @@ def __str__(self):
     "ensure_dir",
     "symlink",
     "copy",
-    "link",
     "symlink",
-    "link",
     "copytree",
 )


Test patch:
diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py
--- a/tests/unit/create/test_creator.py
+++ b/tests/unit/create/test_creator.py
@@ -170,6 +170,26 @@ def list_to_str(iterable):
 
         assert common, "\n".join(difflib.unified_diff(list_to_str(sys_path), list_to_str(system_sys_path)))
 
+    # test that the python executables in the bin directory are either:
+    # - files
+    # - absolute symlinks outside of the venv
+    # - relative symlinks inside of the venv
+    if sys.platform == "win32":
+        exes = ("python.exe",)
+    else:
+        # TODO: also test "python{}.{}" once fixed
+        exes = ("python", "python{}".format(sys.version_info[0]))
+    for exe in exes:
+        exe_path = result.creator.bin_dir / exe
+        assert exe_path.exists()
+        if not exe_path.is_symlink():  # option 1: a real file
+            continue  # it was a file
+        link = os.readlink(str(exe_path))
+        if not os.path.isabs(link):  # option 2: a relative symlink
+            continue
+        # option 3: an absolute symlink, should point outside the venv
+        assert not link.startswith(str(result.creator.dest))
+
 
 @pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv")
 def test_venv_fails_not_inline(tmp_path, capsys, mocker):