Repository: pypa/build (python)

Issue:
Cannot create venv _without_ symlinks using Command Line Tools build of Python on macOS
See https://bugs.python.org/issue38705.  This seems to have bitten some users lately:

* https://github.com/pypa/packaging-problems/issues/493
* https://github.com/pypa/packaging-problems/issues/488

Note that the venv CLI defaults to using symlinks on platforms other than Windows so we could do the same in build.  We could otherwise discourage build users from relying on the macOS-provided Python.

Pull request description:
env: use symlinks except on Windows
Matching the `python -m venv` default, see https://github.com/python/cpython/blob/2e789a1f1d84b343a996e8654590703b5fbdd441/Lib/venv/__init__.py#L403-L406.

Closes #271.


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 8d9cae0..23244e6 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,11 +8,14 @@ Unreleased
 
 - Validate that the supplied source directory is valid (`PR #260`_, Fixes `#259`_)
 - Set and test minimum versions of build's runtime dependencies (`PR #267`_, Fixes `#263`_)
+- Use symlinks on creating venv's when available (`PR #274`_, Fixes `#271`_)
 
-.. _PR #267: https://github.com/pypa/build/pull/267
 .. _PR #260: https://github.com/pypa/build/pull/260
-.. _#263: https://github.com/pypa/build/issues/259
+.. _PR #267: https://github.com/pypa/build/pull/267
+.. _PR #274: https://github.com/pypa/build/pull/274
 .. _#259: https://github.com/pypa/build/issues/259
+.. _#263: https://github.com/pypa/build/issues/263
+.. _#271: https://github.com/pypa/build/issues/271
 
 Breaking Changes
 ----------------
diff --git a/src/build/env.py b/src/build/env.py
index 12ee41a..d6393e6 100644
--- a/src/build/env.py
+++ b/src/build/env.py
@@ -2,6 +2,7 @@
 Creates and manages isolated build environments.
 """
 import abc
+import functools
 import os
 import platform
 import shutil
@@ -187,6 +188,23 @@ def _create_isolated_env_virtualenv(path):  # type: (str) -> Tuple[str, str]
 # venv only exists on Python 3+
 if sys.version_info >= (3,):  # noqa: C901
 
+    @functools.lru_cache(maxsize=None)
+    def _fs_supports_symlink():  # type: () -> bool
+        """Return True if symlinks are supported"""
+        # Using definition used by venv.main()
+        if os.name != 'nt':
+            return True
+
+        # Windows may support symlinks (setting in Windows 10)
+        with tempfile.NamedTemporaryFile(prefix='build-symlink-') as tmp_file:
+            dest = '{}-b'.format(tmp_file)
+            try:
+                os.symlink(tmp_file.name, dest)
+                os.unlink(dest)
+                return True
+            except (OSError, NotImplementedError, AttributeError):
+                return False
+
     def _create_isolated_env_venv(path):  # type: (str) -> Tuple[str, str]
         """
         On Python 3 we use the venv package from the standard library.
@@ -196,7 +214,7 @@ if sys.version_info >= (3,):  # noqa: C901
         """
         import venv
 
-        venv.EnvBuilder(with_pip=True).create(path)
+        venv.EnvBuilder(with_pip=True, symlinks=_fs_supports_symlink()).create(path)
         executable, script_dir, purelib = _find_executable_and_scripts(path)
 
         # Get the version of pip in the environment


Test patch:
diff --git a/tests/test_env.py b/tests/test_env.py
index b461ca5..9efedea 100644
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -153,3 +153,22 @@ def test_pip_needs_upgrade_mac_os_11(mocker, pip_version, arch):
         else:
             (uninstall_call,) = check_call.call_args_list
             assert uninstall_call[0][0][1:] == ['-m', 'pip', 'uninstall', 'setuptools', '-y']
+
+
+@pytest.mark.isolated
+@pytest.mark.skipif(IS_PY2, reason='venv module used on Python 3 only')
+@pytest.mark.skipif(IS_PYPY3 and os.name == 'nt', reason='Isolated tests not supported on PyPy3 + Windows')
+@pytest.mark.parametrize('has_symlink', [True, False] if os.name == 'nt' else [True])
+def test_venv_symlink(mocker, has_symlink):
+    if has_symlink:
+        mocker.patch('os.symlink')
+        mocker.patch('os.unlink')
+    else:
+        mocker.patch('os.symlink', side_effect=OSError())
+
+    # Cache must be cleared to rerun
+    build.env._fs_supports_symlink.cache_clear()
+    supports_symlink = build.env._fs_supports_symlink()
+    build.env._fs_supports_symlink.cache_clear()
+
+    assert supports_symlink is has_symlink