Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog/1175.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Avoid trying to detect symlinks on Windows, regression in 1.6.0 - by :user:`henryiii` (:issue:`1175`)
25 changes: 5 additions & 20 deletions src/build/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class _DistArgs(typing.TypedDict, total=False):

INSTALLERS: tuple[Installer, ...] = typing.get_args(Installer)

# Match the ``venv`` CLI default. Symlinked interpreters on Windows can fail, see #1175.
_USE_SYMLINKS = os.name != 'nt'


class IsolatedEnv(typing.Protocol):
"""Isolated build environment ABC."""
Expand Down Expand Up @@ -361,7 +364,7 @@ def create(self, path: str) -> None:
with_pip = not self._has_valid_outer_pip

try:
venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=with_pip).create(path)
venv.EnvBuilder(symlinks=_USE_SYMLINKS, with_pip=with_pip).create(path)
except subprocess.CalledProcessError as exc:
_ctx.log_subprocess_error(exc)
raise FailedProcessError(exc, 'Failed to create venv. Maybe try installing virtualenv.') from None
Expand Down Expand Up @@ -455,7 +458,7 @@ def create(self, path: str) -> None:
_ctx.log(f'Using external uv from {uv_bin}')
self._uv_bin = uv_bin

venv.EnvBuilder(symlinks=_fs_supports_symlink(), with_pip=False).create(self._env_path)
venv.EnvBuilder(symlinks=_USE_SYMLINKS, with_pip=False).create(self._env_path)
self.python_executable, self.scripts_dir, self.purelib = _find_executable_and_scripts(self._env_path)

def install_dependencies( # pragma: no cover -- uv tests are skipped on PyPy, covered on CPython
Expand Down Expand Up @@ -486,24 +489,6 @@ def display_name(self) -> str:
return 'venv+uv'


@functools.cache
def _fs_supports_symlink() -> bool:
"""Return True if symlinks are supported"""
# Using definition used by venv.main()
if os.name != 'nt':
return True # pragma: win32 no cover

# Windows may support symlinks (setting in Windows 10)
with tempfile.NamedTemporaryFile(prefix='build-symlink-') as tmp_file: # pragma: win32 cover
dest = f'{tmp_file.name}-b'
try:
os.symlink(tmp_file.name, dest)
os.unlink(dest)
except (OSError, NotImplementedError, AttributeError):
return False
return True


def _find_executable_and_scripts(path: str) -> tuple[str, str, str]:
"""
Detect the Python executable and script folder of a virtual environment.
Expand Down
56 changes: 0 additions & 56 deletions tests/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,62 +212,6 @@ def test_pip_needs_upgrade_mac_os_11(
)


@pytest.mark.parametrize('has_symlink', [True, False] if sys.platform.startswith('win') else [True])
def test_venv_symlink(
mocker: pytest_mock.MockerFixture,
has_symlink: bool,
) -> None:
if has_symlink:
mocker.patch('os.symlink')
mocker.patch('os.unlink')
else: # pragma: win32 cover
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


def test_fs_supports_symlink_windows_dest_is_tmp_file_path(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Regression test for the Windows-only branch of ``_fs_supports_symlink``.

``dest`` must be built from ``tmp_file.name``, not from the ``NamedTemporaryFile`` object itself: interpolating the
object yields its repr (containing ``<`` and ``>``, which are invalid in Windows filenames), so ``os.symlink`` would
always fail and the function would always report symlinks as unsupported, even when Windows Developer Mode enables
them.

"""
mocker.patch('os.name', 'nt')

# Avoid exercising the real tempfile module: on some platforms tempfile's own
# internals branch on os.name, which we're patching to 'nt' above.
fake_tmp_file = mocker.MagicMock()
fake_tmp_file.name = r'C:\Users\test\AppData\Local\Temp\build-symlink-abc123'
fake_tmp_file.__enter__.return_value = fake_tmp_file
fake_tmp_file.__exit__.return_value = False
mocker.patch('build.env.tempfile.NamedTemporaryFile', return_value=fake_tmp_file)

recorded_dest = {}

def fake_symlink(_src: str, dst: str) -> None:
recorded_dest['dst'] = dst

mocker.patch('os.symlink', side_effect=fake_symlink)
mocker.patch('os.unlink')

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 True
assert recorded_dest['dst'] == f'{fake_tmp_file.name}-b'


def test_install_short_circuits(
mocker: pytest_mock.MockerFixture,
) -> None:
Expand Down
Loading