diff --git a/pex/atomic_directory.py b/pex/atomic_directory.py index cd7ba5459..a37d07a70 100644 --- a/pex/atomic_directory.py +++ b/pex/atomic_directory.py @@ -4,7 +4,6 @@ from __future__ import absolute_import import errno -import fcntl import hashlib import os import threading @@ -13,8 +12,9 @@ from pex import pex_warnings from pex.common import safe_mkdir, safe_rmtree -from pex.enum import Enum -from pex.typing import TYPE_CHECKING, cast +from pex.fs import lock, safe_rename +from pex.fs.lock import FileLockStyle +from pex.typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Callable, Dict, Iterator, Optional @@ -161,7 +161,7 @@ def finalize(self, source=None): # # We have satisfied the single filesystem constraint by arranging the `work_dir` to be a # sibling of the `target_dir`. - os.rename(source, self._target_dir) + safe_rename(source, self._target_dir) except OSError as e: if e.errno not in (errno.EEXIST, errno.ENOTEMPTY): raise e @@ -173,19 +173,8 @@ def cleanup(self): safe_rmtree(self._work_dir) -class FileLockStyle(Enum["FileLockStyle.Value"]): - class Value(Enum.Value): - pass - - BSD = Value("bsd") - POSIX = Value("posix") - - -FileLockStyle.seal() - - -def _is_bsd_lock(lock_style=None): - # type: (Optional[FileLockStyle.Value]) -> bool +def _lock_style(lock_style=None): + # type: (Optional[FileLockStyle.Value]) -> FileLockStyle.Value # The atomic_directory file locking has used POSIX locks since inception. These have maximum # compatibility across OSes and stand a decent chance of working over modern NFS. With the @@ -194,10 +183,9 @@ def _is_bsd_lock(lock_style=None): # `lock_style` to atomic_directory. In order to allow experimenting with / debugging possible # file locking bugs, we allow a `_PEX_FILE_LOCK_STYLE` back door private ~API to upgrade all # locks to BSD style locks. This back door can be removed at any time. - file_lock_style = lock_style or FileLockStyle.for_value( + return lock_style or FileLockStyle.for_value( os.environ.get("_PEX_FILE_LOCK_STYLE", FileLockStyle.POSIX.value) ) - return file_lock_style is FileLockStyle.BSD @attr.s(frozen=True) @@ -209,29 +197,14 @@ class _FileLock(object): def acquire(self): # type: () -> Callable[[], None] self._in_process_lock.acquire() - - # N.B.: We don't actually write anything to the lock file but the fcntl file locking - # operations only work on files opened for at least write. - safe_mkdir(os.path.dirname(self._path)) - lock_fd = os.open(self._path, os.O_CREAT | os.O_WRONLY) - - lock_api = cast( - "Callable[[int, int], None]", - fcntl.flock if _is_bsd_lock(self._style) else fcntl.lockf, - ) - - # N.B.: Since lockf and flock operate on an open file descriptor and these are - # guaranteed to be closed by the operating system when the owning process exits, - # this lock is immune to staleness. - lock_api(lock_fd, fcntl.LOCK_EX) # A blocking write lock. + file_lock = lock.acquire(self._path, exclusive=True, style=_lock_style(self._style)) def release(): # type: () -> None try: - lock_api(lock_fd, fcntl.LOCK_UN) + file_lock.release() finally: - os.close(lock_fd) - self._in_process_lock.release() + self._in_process_lock.release() return release diff --git a/pex/cache/access.py b/pex/cache/access.py index 1fa1c0275..9c187105b 100644 --- a/pex/cache/access.py +++ b/pex/cache/access.py @@ -3,12 +3,14 @@ from __future__ import absolute_import, print_function -import fcntl import itertools import os from contextlib import contextmanager -from pex.common import safe_mkdir, touch +from pex.common import touch +from pex.fs import lock +from pex.fs.lock import FileLockStyle +from pex.os import WINDOWS from pex.typing import TYPE_CHECKING from pex.variables import ENV @@ -64,21 +66,14 @@ def _lock(exclusive): existing_exclusive, lock_fd, existing_lock_file = _LOCK if existing_exclusive == exclusive: return existing_lock_file + elif WINDOWS: + # Windows shared locks are not re-entrant; so this is the best we can do. + lock.release(lock_fd) lock_file = os.path.join(ENV.PEX_ROOT, "access.lck") - if lock_fd is None: - # N.B.: We don't actually write anything to the lock file but the fcntl file locking - # operations only work on files opened for at least write. - safe_mkdir(os.path.dirname(lock_file)) - lock_fd = os.open(lock_file, os.O_CREAT | os.O_WRONLY) - - # N.B.: Since flock operates on an open file descriptor and these are - # guaranteed to be closed by the operating system when the owning process exits, - # this lock is immune to staleness. - fcntl.flock(lock_fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) - - _LOCK = exclusive, lock_fd, lock_file + file_lock = lock.acquire(lock_file, exclusive=exclusive, style=FileLockStyle.BSD, fd=lock_fd) + _LOCK = exclusive, file_lock.fd, lock_file return lock_file diff --git a/pex/cache/dirs.py b/pex/cache/dirs.py index d10753544..338cf61db 100644 --- a/pex/cache/dirs.py +++ b/pex/cache/dirs.py @@ -10,8 +10,8 @@ from pex.compatibility import commonpath from pex.enum import Enum from pex.exceptions import production_assert -from pex.executables import is_exe from pex.orderedset import OrderedSet +from pex.os import is_exe from pex.typing import TYPE_CHECKING, cast from pex.variables import ENV, Variables diff --git a/pex/cli/commands/lock.py b/pex/cli/commands/lock.py index 2b240e014..91dfc32ef 100644 --- a/pex/cli/commands/lock.py +++ b/pex/cli/commands/lock.py @@ -30,9 +30,9 @@ ) from pex.enum import Enum from pex.exceptions import production_assert -from pex.executables import is_exe from pex.interpreter import PythonInterpreter from pex.orderedset import OrderedSet +from pex.os import is_exe, safe_execv from pex.pep_376 import InstalledWheel, Record from pex.pep_427 import InstallableType from pex.pep_440 import Version @@ -344,7 +344,7 @@ def sync( if self.command: try: - os.execv(self.command[0], self.command) + safe_execv(self.command) except OSError as e: return Error("Failed to execute {exe}: {err}".format(exe=self.command[0], err=e)) diff --git a/pex/common.py b/pex/common.py index 0c239ecfa..951c30c00 100644 --- a/pex/common.py +++ b/pex/common.py @@ -24,6 +24,7 @@ from pex.enum import Enum from pex.executables import chmod_plus_x +from pex.fs import safe_link, safe_rename, safe_symlink from pex.typing import TYPE_CHECKING, cast if TYPE_CHECKING: @@ -119,13 +120,13 @@ def do_copy(): # type: () -> None temp_dest = dest + uuid4().hex shutil.copy(source, temp_dest) - os.rename(temp_dest, dest) + safe_rename(temp_dest, dest) # If the platform supports hard-linking, use that and fall back to copying. # Windows does not support hard-linking. if hasattr(os, "link"): try: - os.link(source, dest) + safe_link(source, dest) except OSError as e: if e.errno == errno.EEXIST: # File already exists. If overwrite=True, write otherwise skip. @@ -618,7 +619,7 @@ def symlink( self._ensure_parent(dst) abs_src = os.path.realpath(src) abs_dst = os.path.realpath(os.path.join(self.chroot, dst)) - os.symlink(os.path.relpath(abs_src, os.path.dirname(abs_dst)), abs_dst) + safe_symlink(os.path.relpath(abs_src, os.path.dirname(abs_dst)), abs_dst) def write( self, @@ -779,7 +780,7 @@ def relative_symlink( """ dst_parent = os.path.dirname(dst) rel_src = os.path.relpath(src, dst_parent) - os.symlink(rel_src, dst) + safe_symlink(rel_src, dst) class CopyMode(Enum["CopyMode.Value"]): @@ -839,7 +840,7 @@ def iter_copytree( # later go missing leaving the dst_entry dangling. if link and not os.path.islink(src_entry): try: - os.link(src_entry, dst_entry) + safe_link(src_entry, dst_entry) continue except OSError as e: if e.errno != errno.EXDEV: diff --git a/pex/compatibility.py b/pex/compatibility.py index 7d375abe5..0ceab59ce 100644 --- a/pex/compatibility.py +++ b/pex/compatibility.py @@ -178,8 +178,6 @@ def cpu_count(): from Queue import Queue as Queue -WINDOWS = os.name == "nt" - # Universal newlines is the default in Python 3. MODE_READ_UNIVERSAL_NEWLINES = "rU" if PY2 else "r" diff --git a/pex/executables.py b/pex/executables.py index 34a28172f..d653d3b7a 100644 --- a/pex/executables.py +++ b/pex/executables.py @@ -8,6 +8,7 @@ import stat from textwrap import dedent +from pex.os import is_exe from pex.typing import TYPE_CHECKING if TYPE_CHECKING: @@ -28,16 +29,6 @@ def chmod_plus_x(path): os.chmod(path, path_mode) -def is_exe(path): - # type: (Text) -> bool - """Determines if the given path is a file executable by the current user. - - :param path: The path to check. - :return: True if the given path is a file executable by the current user. - """ - return os.path.isfile(path) and os.access(path, os.R_OK | os.X_OK) - - def is_script( path, # type: Text pattern=None, # type: Optional[bytes] diff --git a/pex/finders.py b/pex/finders.py index fb80c5227..6be613d45 100644 --- a/pex/finders.py +++ b/pex/finders.py @@ -17,6 +17,7 @@ from pex.executables import is_python_script from pex.pep_376 import InstalledWheel from pex.pep_503 import ProjectName +from pex.sysconfig import SCRIPT_DIR, script_name from pex.typing import TYPE_CHECKING, cast from pex.wheel import Wheel @@ -38,14 +39,16 @@ def find( ): # type: (...) -> Optional[DistributionScript] if dist.type is DistributionType.WHEEL: - script_path = Wheel.load(dist.location).data_path("scripts", name) + script_path = Wheel.load(dist.location).data_path("scripts", script_name(name)) with open_zip(dist.location) as zfp: try: zfp.getinfo(script_path) except KeyError: return None elif dist.type is DistributionType.INSTALLED: - script_path = InstalledWheel.load(dist.location).stashed_path("bin", name) + script_path = InstalledWheel.load(dist.location).stashed_path( + SCRIPT_DIR, script_name(name) + ) if not os.path.isfile(script_path): return None else: diff --git a/pex/fs/__init__.py b/pex/fs/__init__.py new file mode 100644 index 000000000..704a5cead --- /dev/null +++ b/pex/fs/__init__.py @@ -0,0 +1,125 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import os +import sys + +from pex.os import WINDOWS +from pex.typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Text + +if WINDOWS and not hasattr(os, "replace"): + _MOVEFILE_REPLACE_EXISTING = 0x1 + + _MF = None + + def safe_rename( + src, # type: Text + dst, # type: Text + ): + # type: (...) -> None + + import ctypes + from ctypes.wintypes import BOOL, DWORD, LPCWSTR + + global _MF + if _MF is None: + mf = ctypes.windll.kernel32.MoveFileExW # type: ignore[attr-defined] + mf.argtypes = ( + # lpExistingFileName + LPCWSTR, + # lpNewFileName + LPCWSTR, + # dwFlags + DWORD, + ) + mf.restype = BOOL + _MF = mf + + # See: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw + if not _MF(src, dst, _MOVEFILE_REPLACE_EXISTING): + raise ctypes.WinError() # type: ignore[attr-defined] + +else: + safe_rename = getattr(os, "replace", os.rename) + + +# N.B.: Python 3.7 has os.symlink on Windows, but the implementation does not pass the +# _SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE flag. +if WINDOWS and (not hasattr(os, "symlink") or sys.version_info[:2] < (3, 8)): + _SYMBOLIC_LINK_FLAG_FILE = 0x0 + _SYMBOLIC_LINK_FLAG_DIRECTORY = 0x1 + _SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x2 + + _CSL = None + + def safe_symlink( + src, # type: Text + dst, # type: Text + ): + # type: (...) -> None + + import ctypes + from ctypes.wintypes import BOOLEAN, DWORD, LPCWSTR + + global _CSL + if _CSL is None: + csl = ctypes.windll.kernel32.CreateSymbolicLinkW # type: ignore[attr-defined] + csl.argtypes = ( + # lpSymlinkFileName + LPCWSTR, + # lpTargetFileName + LPCWSTR, + # dwFlags + DWORD, + ) + csl.restype = BOOLEAN + _CSL = csl + + # See: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createsymboliclinkw + flags = _SYMBOLIC_LINK_FLAG_DIRECTORY if os.path.isdir(src) else _SYMBOLIC_LINK_FLAG_FILE + flags |= _SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE + if not _CSL(dst, src, flags): + raise ctypes.WinError() # type: ignore[attr-defined] + +else: + safe_realpath = os.path.realpath + safe_symlink = getattr(os, "symlink") + + +if WINDOWS and not hasattr(os, "link"): + _CHL = None + + def safe_link( + src, # type: Text + dst, # type: Text + ): + # type: (...) -> None + + import ctypes + from ctypes.wintypes import BOOL, LPCWSTR, LPVOID + + global _CHL + if _CHL is None: + # See: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createhardlinkw + chl = ctypes.windll.kernel32.CreateHardLinkW # type: ignore[attr-defined] + chl.argtypes = ( + # lpFileName + LPCWSTR, + # lpExistingFileName + LPCWSTR, + # lpSecurityAttributes (Reserved; must be NULL) + LPVOID, + ) + chl.restype = BOOL + _CHL = chl + + if not _CHL(dst, src, None): + raise ctypes.WinError() # type: ignore[attr-defined] + +else: + safe_link = getattr(os, "link") diff --git a/pex/fs/_posix.py b/pex/fs/_posix.py new file mode 100644 index 000000000..59480dcc2 --- /dev/null +++ b/pex/fs/_posix.py @@ -0,0 +1,44 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import fcntl + +from pex.fs.lock import FileLock, FileLockStyle +from pex.typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from typing import Callable + + +class PosixFileLock(FileLock): + @staticmethod + def _lock_api(style): + # type: (FileLockStyle.Value) -> Callable[[int, int], None] + + return cast( + "Callable[[int, int], None]", fcntl.flock if style is FileLockStyle.BSD else fcntl.lockf + ) + + @classmethod + def acquire( + cls, + fd, # type: int + exclusive, # type: bool + style, # type: FileLockStyle.Value + ): + # type: (...) -> PosixFileLock + + cls._lock_api(style)(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + return cls(locked_fd=fd, unlock=lambda: cls.release_lock(fd, style=style)) + + @classmethod + def release_lock( + cls, + fd, # type: int + style, # type: FileLockStyle.Value + ): + # type: (...) -> None + + cls._lock_api(style)(fd, fcntl.LOCK_UN) diff --git a/pex/fs/_windows.py b/pex/fs/_windows.py new file mode 100644 index 000000000..60ed6ae37 --- /dev/null +++ b/pex/fs/_windows.py @@ -0,0 +1,111 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import ctypes +import msvcrt +from ctypes.wintypes import BOOL, DWORD, HANDLE, LPVOID, PULONG, ULONG + +from pex.fs.lock import FileLock +from pex.typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional + + +class Offset(ctypes.Structure): + _fields_ = [ + ("Offset", DWORD), + ("OffsetHigh", DWORD), + ] + + +class OffsetUnion(ctypes.Union): + _fields_ = [("Offset", Offset), ("Pointer", LPVOID)] + + +# See: https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-overlapped +class Overlapped(ctypes.Structure): + @classmethod + def ignored(cls): + # type: () -> Overlapped + return cls(PULONG(ULONG(0)), PULONG(ULONG(0)), OffsetUnion(Offset(0, 0)), HANDLE(0)) + + _fields_ = [ + ("Internal", PULONG), + ("InternalHigh", PULONG), + ("OffsetUnion", OffsetUnion), + ("hEvent", HANDLE), + ] + + +# See: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex +_LockFileEx = ctypes.windll.kernel32.LockFileEx # type: ignore[attr-defined] +_LockFileEx.argtypes = ( + HANDLE, # hFile + DWORD, # dwFlags + DWORD, # dwReserved + DWORD, # nNumberOfBytesToLockLow + DWORD, # nNumberOfBytesToLockHigh + Overlapped, # lpOverlapped +) +_LockFileEx.restype = BOOL +_LOCKFILE_EXCLUSIVE_LOCK = 0x2 + + +# See: https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-unlockfileex +_UnlockFileEx = ctypes.windll.kernel32.UnlockFileEx # type: ignore[attr-defined] +_UnlockFileEx.argtypes = ( + HANDLE, # hFile + DWORD, # dwReserved + DWORD, # nNumberOfBytesToLockLow + DWORD, # nNumberOfBytesToLockHigh + Overlapped, # lpOverlapped +) +_UnlockFileEx.restype = BOOL + + +class WindowsFileLock(FileLock): + @classmethod + def acquire( + cls, + fd, # type: int + exclusive, # type: bool + ): + # type: (...) -> WindowsFileLock + + mode = 0 # The default is a shared lock. + if exclusive: + mode |= _LOCKFILE_EXCLUSIVE_LOCK + + overlapped = Overlapped.ignored() + fhandle = msvcrt.get_osfhandle(fd) # type: ignore[attr-defined] + if not _LockFileEx( + HANDLE(fhandle), # hFile + DWORD(mode), # dwFlags + DWORD(0), # dwReserved + DWORD(1), # nNumberOfBytesToLockLow + DWORD(0), # nNumberOfBytesToLockHigh + overlapped, # lpOverlapped + ): + raise ctypes.WinError() # type: ignore[attr-defined] + return cls(locked_fd=fd, unlock=lambda: cls.release_lock(fd, overlapped=overlapped)) + + @classmethod + def release_lock( + cls, + fd, # type: int + overlapped=None, # type: Optional[Overlapped] + ): + # type: (...) -> None + + fhandle = msvcrt.get_osfhandle(fd) # type: ignore[attr-defined] + if not _UnlockFileEx( + HANDLE(fhandle), # hFile + DWORD(0), # dwReserved + DWORD(1), # nNumberOfBytesToLockLow + DWORD(0), # nNumberOfBytesToLockHigh + overlapped or Overlapped.ignored(), # lpOverlapped + ): + raise ctypes.WinError() # type: ignore[attr-defined] diff --git a/pex/fs/lock.py b/pex/fs/lock.py new file mode 100644 index 000000000..b6743b789 --- /dev/null +++ b/pex/fs/lock.py @@ -0,0 +1,89 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import os + +from pex.common import safe_mkdir +from pex.enum import Enum +from pex.os import WINDOWS +from pex.typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + + import attr # vendor: skip +else: + from pex.third_party import attr + + +class FileLockStyle(Enum["FileLockStyle.Value"]): + class Value(Enum.Value): + pass + + BSD = Value("bsd") + POSIX = Value("posix") + + +FileLockStyle.seal() + + +@attr.s(frozen=True) +class FileLock(object): + _locked_fd = attr.ib() # type: int + _unlock = attr.ib() # type: Callable[[], Any] + + @property + def fd(self): + # type: () -> int + return self._locked_fd + + def release(self): + # type: () -> None + try: + self._unlock() + finally: + os.close(self._locked_fd) + + +def acquire( + path, # type: str + exclusive=True, # type: bool + style=FileLockStyle.POSIX, # type: FileLockStyle.Value + fd=None, # type: Optional[int] +): + # type: (...) -> FileLock + + if fd: + lock_fd = fd + else: + # N.B.: We don't actually write anything to the lock file but the fcntl file locking + # operations only work on files opened for at least write. + safe_mkdir(os.path.dirname(path)) + lock_fd = os.open(path, os.O_CREAT | os.O_WRONLY) + + if WINDOWS: + from pex.fs._windows import WindowsFileLock + + return WindowsFileLock.acquire(lock_fd, exclusive=exclusive) + else: + from pex.fs._posix import PosixFileLock + + return PosixFileLock.acquire(lock_fd, exclusive=exclusive, style=style) + + +def release( + fd, # type: int + style=FileLockStyle.POSIX, # type: FileLockStyle.Value +): + # type: (...) -> None + + if WINDOWS: + from pex.fs._windows import WindowsFileLock + + WindowsFileLock.release_lock(fd) + else: + from pex.fs._posix import PosixFileLock + + PosixFileLock.release_lock(fd, style=style) diff --git a/pex/interpreter.py b/pex/interpreter.py index 833022e91..7a056a51f 100644 --- a/pex/interpreter.py +++ b/pex/interpreter.py @@ -18,15 +18,16 @@ from pex import third_party from pex.cache.dirs import InterpreterDir from pex.common import safe_mkdtemp, safe_rmtree -from pex.executables import is_exe from pex.executor import Executor from pex.jobs import Job, Retain, SpawnedJob, execute_parallel from pex.orderedset import OrderedSet +from pex.os import WINDOWS, is_exe from pex.pep_425 import CompatibilityTags from pex.pep_508 import MarkerEnvironment from pex.platforms import Platform from pex.pth import iter_pth_paths from pex.pyenv import Pyenv +from pex.sysconfig import EXE_EXTENSION, SCRIPT_DIR, script_name from pex.third_party.packaging import __version__ as packaging_version from pex.third_party.packaging import tags from pex.tracer import TRACER @@ -140,8 +141,7 @@ def _adjust_to_final_path(path): # type: (str) -> str for current_path, final_path in _PATH_MAPPINGS.items(): if path.startswith(current_path): - prefix_pattern = re.escape(current_path) - return re.sub(prefix_pattern, final_path, path) + return final_path + path[len(current_path) :] return path @@ -149,8 +149,7 @@ def _adjust_to_current_path(path): # type: (str) -> str for current_path, final_path in _PATH_MAPPINGS.items(): if path.startswith(final_path): - prefix_pattern = re.escape(final_path) - return re.sub(prefix_pattern, current_path, path) + return current_path + path[len(final_path) :] return path @@ -798,7 +797,7 @@ def include_system_site_packages(self): class PythonInterpreter(object): _REGEXEN = ( # NB: OSX ships python binaries named Python with a capital-P; so we allow for this. - re.compile(r"^Python$"), + re.compile(r"^Python{extension}$".format(extension=re.escape(EXE_EXTENSION))), re.compile( r""" ^ @@ -818,8 +817,11 @@ class PythonInterpreter(object): [a-z]? )? )? + {extension} $ - """, + """.format( + extension=re.escape(EXE_EXTENSION) + ), flags=re.VERBOSE, ), ) @@ -1423,11 +1425,17 @@ def resolve_base_interpreter(self): prefix = "pypy" if self.is_pypy else "python" suffixes = ("{}.{}".format(version[0], version[1]), str(version[0]), "") - candidate_binaries = tuple("{}{}".format(prefix, suffix) for suffix in suffixes) + candidate_binaries = tuple( + script_name("{}{}".format(prefix, suffix)) for suffix in suffixes + ) def iter_base_candidate_binary_paths(interpreter): # type: (PythonInterpreter) -> Iterator[str] - bin_dir = os.path.join(interpreter._identity.base_prefix, "bin") + bin_dir = ( + interpreter._identity.base_prefix + if WINDOWS + else os.path.join(interpreter._identity.base_prefix, SCRIPT_DIR) + ) for candidate_binary in candidate_binaries: candidate_binary_path = os.path.join(bin_dir, candidate_binary) if is_exe(candidate_binary_path): diff --git a/pex/layout.py b/pex/layout.py index 5f5699fef..7b67cbfba 100644 --- a/pex/layout.py +++ b/pex/layout.py @@ -14,6 +14,7 @@ from pex.common import ZipFileEx, open_zip, safe_copy, safe_mkdir, safe_mkdtemp from pex.enum import Enum from pex.executables import is_script +from pex.fs import safe_symlink from pex.tracer import TRACER from pex.typing import TYPE_CHECKING from pex.variables import ENV, unzip_dir @@ -192,7 +193,7 @@ def _install_distribution( ) safe_mkdir(os.path.dirname(symlink_dest)) - os.symlink(symlink_src, symlink_dest) + safe_symlink(symlink_src, symlink_dest) return location @@ -352,7 +353,7 @@ def _ensure_installed( ) as bootstrap_zip_chroot: if not bootstrap_zip_chroot.is_finalized(): layout.extract_bootstrap(bootstrap_zip_chroot.work_dir) - os.symlink( + safe_symlink( os.path.join(os.path.relpath(bootstrap_cache, install_to)), os.path.join(chroot.work_dir, BOOTSTRAP_DIR), ) @@ -368,7 +369,7 @@ def _ensure_installed( if not code_chroot.is_finalized(): layout.extract_code(code_chroot.work_dir) for path in os.listdir(code_cache): - os.symlink( + safe_symlink( os.path.join(os.path.relpath(code_cache, install_to), path), os.path.join(chroot.work_dir, path), ) diff --git a/pex/os.py b/pex/os.py new file mode 100644 index 000000000..1796d0a5b --- /dev/null +++ b/pex/os.py @@ -0,0 +1,84 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import os +import sys + +from pex.typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, List, NoReturn, Text, Tuple, Union + +# N.B.: Python 2.7 uses "linux2". +LINUX = sys.platform.startswith("linux") +MAC = sys.platform == "darwin" +WINDOWS = sys.platform == "win32" + + +HOME_ENV_VAR = "USERPROFILE" if WINDOWS else "HOME" + + +if WINDOWS: + + def safe_execv(argv): + # type: (Union[List[str], Tuple[str, ...]]) -> NoReturn + import subprocess + import sys + + sys.exit(subprocess.call(args=argv)) + +else: + + def safe_execv(argv): + # type: (Union[List[str], Tuple[str, ...]]) -> NoReturn + os.execv(argv[0], argv) + + +if WINDOWS: + _GBT = None # type: Any + + def is_exe(path): + # type: (Text) -> bool + + if not os.path.isfile(path): + return False + + from pex.sysconfig import EXE_EXTENSIONS + + _, ext = os.path.splitext(path) + if ext.lower() in EXE_EXTENSIONS: + return True + + import ctypes + from ctypes.wintypes import BOOL, DWORD, LPCWSTR, LPDWORD + + global _GBT + if _GBT is None: + gbt = ctypes.windll.kernel32.GetBinaryTypeW # type: ignore[attr-defined] + gbt.argtypes = ( + # lpApplicationName + LPCWSTR, + # lpBinaryType + LPDWORD, + ) + gbt.restype = BOOL + _GBT = gbt + + # See: https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getbinarytypew + # N.B.: We don't care about the binary type, just the bool which tells us it is or is not an + # executable. + _binary_type = DWORD(0) + return bool(_GBT(path, ctypes.byref(_binary_type))) + +else: + + def is_exe(path): + # type: (Text) -> bool + """Determines if the given path is a file executable by the current user. + + :param path: The path to check. + :return: `True if the given path is a file executable by the current user. + """ + return os.path.isfile(path) and os.access(path, os.R_OK | os.X_OK) diff --git a/pex/pep_376.py b/pex/pep_376.py index fb99cdf03..e38bc7fe7 100644 --- a/pex/pep_376.py +++ b/pex/pep_376.py @@ -15,6 +15,7 @@ from pex import hashing from pex.common import CopyMode, is_pyc_dir, is_pyc_file, safe_mkdir, safe_open +from pex.fs import safe_link, safe_symlink from pex.interpreter import PythonInterpreter from pex.typing import TYPE_CHECKING, cast from pex.util import CacheHelper @@ -372,7 +373,7 @@ def _reinstall_stash( # go missing leaving the dst dangling. if link and not os.path.islink(src): try: - os.link(src, dst) + safe_link(src, dst) continue except OSError as e: if e.errno != errno.EXDEV: @@ -415,7 +416,7 @@ def _reinstall_site_packages( dst_parent = os.path.dirname(dst_entry) safe_mkdir(dst_parent) rel_src = os.path.relpath(src_entry, dst_parent) - os.symlink(rel_src, dst_entry) + safe_symlink(rel_src, dst_entry) traverse.discard(path) elif is_dir: safe_mkdir(dst_entry) @@ -425,7 +426,7 @@ def _reinstall_site_packages( # target could later go missing leaving the dst_entry dangling. if link and not os.path.islink(src_entry): try: - os.link(src_entry, dst_entry) + safe_link(src_entry, dst_entry) continue except OSError as e: if e.errno != errno.EXDEV: diff --git a/pex/pep_427.py b/pex/pep_427.py index 291598476..d1c66cec2 100644 --- a/pex/pep_427.py +++ b/pex/pep_427.py @@ -22,6 +22,7 @@ from pex.interpreter import PythonInterpreter from pex.pep_376 import InstalledFile, InstalledWheel, Record from pex.pep_503 import ProjectName +from pex.sysconfig import SCRIPT_DIR from pex.typing import TYPE_CHECKING, cast from pex.wheel import Wheel @@ -74,7 +75,7 @@ def chroot( purelib=destination, platlib=destination, headers=os.path.join(base, "include", "site", "pythonX.Y", project_name.raw), - scripts=os.path.join(base, "bin"), + scripts=os.path.join(base, SCRIPT_DIR), data=base, ) diff --git a/pex/pex.py b/pex/pex.py index 2ce7c478a..8a9177f7f 100644 --- a/pex/pex.py +++ b/pex/pex.py @@ -23,6 +23,7 @@ from pex.interpreter import PythonIdentity, PythonInterpreter from pex.layout import Layout from pex.orderedset import OrderedSet +from pex.os import safe_execv from pex.pex_info import PexInfo from pex.targets import LocalInterpreter from pex.tracer import TRACER @@ -719,7 +720,7 @@ def execute_with_options( for arg in python_options ): os.environ["PYTHONINSPECT"] = "1" - os.execv(python, cmdline) + safe_execv(cmdline) def execute_script(self, script_name): # type: (str) -> Any diff --git a/pex/pex_boot.py b/pex/pex_boot.py index fa7d5755e..b144a6da1 100644 --- a/pex/pex_boot.py +++ b/pex/pex_boot.py @@ -53,11 +53,13 @@ def __re_exec__( ): # type: (...) -> NoReturn + from pex.os import safe_execv + argv = [python] argv.extend(python_args) argv.extend(extra_python_args) - os.execv(python, argv + sys.argv[1:]) + safe_execv(argv + sys.argv[1:]) __SHOULD_EXECUTE__ = __name__ == "__main__" @@ -115,7 +117,8 @@ def __maybe_run_venv__( ): # type: (...) -> Optional[str] - from pex.executables import is_exe + from pex.os import is_exe + from pex.sysconfig import SCRIPT_DIR, script_name from pex.tracer import TRACER from pex.variables import venv_dir @@ -133,7 +136,7 @@ def __maybe_run_venv__( return venv_root_dir TRACER.log("Executing venv PEX for {pex} at {venv_pex}".format(pex=pex, venv_pex=venv_pex)) - venv_python = os.path.join(venv_root_dir, "bin", "python") + venv_python = os.path.join(venv_root_dir, SCRIPT_DIR, script_name("python")) if hermetic_venv_scripts: __re_exec__(venv_python, python_args, "-sE", venv_pex) else: diff --git a/pex/pex_bootstrapper.py b/pex/pex_bootstrapper.py index d4fcda49a..b36d6ea7b 100644 --- a/pex/pex_bootstrapper.py +++ b/pex/pex_bootstrapper.py @@ -13,6 +13,7 @@ from pex.cache.dirs import VenvDirs from pex.common import CopyMode, die, pluralize from pex.environment import ResolveError +from pex.fs import safe_symlink from pex.inherit_path import InheritPath from pex.interpreter import PythonInterpreter from pex.interpreter_constraints import ( @@ -21,7 +22,9 @@ ) from pex.layout import Layout from pex.orderedset import OrderedSet +from pex.os import safe_execv from pex.pex_info import PexInfo +from pex.sysconfig import SCRIPT_DIR, script_name from pex.targets import LocalInterpreter from pex.tracer import TRACER from pex.typing import TYPE_CHECKING, cast @@ -451,7 +454,7 @@ def maybe_reexec_pex( # Avoid a re-run through compatibility_constraint checking. os.environ[current_interpreter_blessed_env_var] = "1" - os.execv(target_binary, cmdline) + safe_execv(cmdline) def _bootstrap(entry_point): @@ -471,7 +474,7 @@ class VenvPex(object): def bin_file(self, name): # type: (str) -> str - return os.path.join(self.venv_dir, "bin", name) + return os.path.join(self.venv_dir, SCRIPT_DIR, script_name(name)) def __attrs_post_init__(self): # type: () -> None @@ -498,9 +501,7 @@ def execv( additional_args=(), # type: Sequence[str] ): # type: (...) -> NoReturn - os.execv( - self.python, self.execute_args(python_args=python_args, additional_args=additional_args) - ) + safe_execv(self.execute_args(python_args=python_args, additional_args=additional_args)) def ensure_venv( @@ -572,7 +573,7 @@ def ensure_venv( continue with interpreter.path_mapping(short_venv.work_dir, venv_dirs.short_dir): - os.symlink( + safe_symlink( os.path.relpath(venv_dirs, venv_dirs.short_dir), os.path.join(short_venv.work_dir, venv_dirs.SHORT_SYMLINK_NAME), ) @@ -597,7 +598,7 @@ def ensure_venv( python=os.path.join( venv_dirs.short_dir, "venv", - "bin", + SCRIPT_DIR, os.path.basename(virtualenv.interpreter.binary), ), collisions_ok=collisions_ok, @@ -707,7 +708,7 @@ def _activate_venv_dir( venv_python = None if venv_dir: - python = os.path.join(venv_dir, "bin", "python") + python = os.path.join(venv_dir, SCRIPT_DIR, script_name("python")) if os.path.exists(python): venv_python = python diff --git a/pex/pex_builder.py b/pex/pex_builder.py index a3a205afd..d1d9b63eb 100644 --- a/pex/pex_builder.py +++ b/pex/pex_builder.py @@ -32,6 +32,7 @@ from pex.enum import Enum from pex.executables import chmod_plus_x, create_sh_python_redirector_shebang from pex.finders import get_entry_point_from_console_script, get_script_from_distributions +from pex.fs import safe_rename, safe_symlink from pex.interpreter import PythonInterpreter from pex.layout import Layout from pex.orderedset import OrderedSet @@ -551,7 +552,7 @@ def _prepare_bootstrap(self): ) bootstrap_digest = hashlib.sha1() - bootstrap_packages = ["cache", "repl", "third_party", "venv"] + bootstrap_packages = ["cache", "fs", "repl", "third_party", "venv"] if self._pex_info.includes_tools: bootstrap_packages.extend(["commands", "tools"]) @@ -671,15 +672,15 @@ def build( main_fp.readline() # Throw away shebang line. shutil.copyfileobj(main_fp, script_fp) chmod_plus_x(pex_script) - os.rename(pex_script, main_py) - os.symlink("__main__.py", pex_script) + safe_rename(pex_script, main_py) + safe_symlink("__main__.py", pex_script) if os.path.isdir(path): shutil.rmtree(path, True) elif os.path.isdir(tmp_pex): safe_delete(path) check.perform_check(layout, tmp_pex) - os.rename(tmp_pex, path) + safe_rename(tmp_pex, path) def set_sh_boot_script( self, diff --git a/pex/pex_info.py b/pex/pex_info.py index b90fc899a..d944bd7e3 100644 --- a/pex/pex_info.py +++ b/pex/pex_info.py @@ -10,10 +10,11 @@ from pex import layout, pex_warnings, variables from pex.cache import root as cache_root from pex.common import can_write_dir, open_zip, safe_mkdtemp -from pex.compatibility import PY2, WINDOWS +from pex.compatibility import PY2 from pex.compatibility import string as compatibility_string from pex.inherit_path import InheritPath from pex.orderedset import OrderedSet +from pex.os import WINDOWS from pex.typing import TYPE_CHECKING, cast from pex.variables import ENV, Variables from pex.venv.bin_path import BinPath diff --git a/pex/pip/installation.py b/pex/pip/installation.py index 4bff7af87..5a8868366 100644 --- a/pex/pip/installation.py +++ b/pex/pip/installation.py @@ -16,6 +16,7 @@ from pex.dist_metadata import Requirement from pex.exceptions import production_assert from pex.executor import Executor +from pex.fs import safe_symlink from pex.interpreter import PythonInterpreter from pex.jobs import iter_map_parallel from pex.orderedset import OrderedSet @@ -234,7 +235,7 @@ def _install_wheel(wheel_path): relative_target_path = os.path.relpath( installed_wheel_dir, runtime_key_dir.symlink_dir ) - os.symlink(relative_target_path, source_path) + safe_symlink(relative_target_path, source_path) return installed_wheel_dir diff --git a/pex/pyenv.py b/pex/pyenv.py index 38988fe00..589abd0cf 100644 --- a/pex/pyenv.py +++ b/pex/pyenv.py @@ -7,7 +7,7 @@ import re import subprocess -from pex.executables import is_exe +from pex.os import is_exe from pex.tracer import TRACER from pex.typing import TYPE_CHECKING diff --git a/pex/repl/pex_repl.py b/pex/repl/pex_repl.py index 042d7e9ce..eb479368a 100644 --- a/pex/repl/pex_repl.py +++ b/pex/repl/pex_repl.py @@ -14,8 +14,8 @@ from pex.common import pluralize from pex.compatibility import commonpath from pex.dist_metadata import Distribution -from pex.executables import is_exe from pex.layout import Layout +from pex.os import is_exe from pex.pex_info import PexInfo from pex.repl import custom from pex.repl.custom import repl_loop diff --git a/pex/resolve/lock_downloader.py b/pex/resolve/lock_downloader.py index 36723ec44..a41526e8e 100644 --- a/pex/resolve/lock_downloader.py +++ b/pex/resolve/lock_downloader.py @@ -9,11 +9,11 @@ from multiprocessing.pool import ThreadPool from pex import resolver -from pex.atomic_directory import FileLockStyle from pex.auth import PasswordDatabase, PasswordEntry from pex.common import pluralize from pex.compatibility import cpu_count from pex.dist_metadata import Requirement +from pex.fs.lock import FileLockStyle from pex.network_configuration import NetworkConfiguration from pex.pep_503 import ProjectName from pex.pip.local_project import digest_local_project diff --git a/pex/resolve/lockfile/download_manager.py b/pex/resolve/lockfile/download_manager.py index b748472d1..f87910d69 100644 --- a/pex/resolve/lockfile/download_manager.py +++ b/pex/resolve/lockfile/download_manager.py @@ -8,9 +8,10 @@ import os from pex import hashing -from pex.atomic_directory import FileLockStyle, atomic_directory +from pex.atomic_directory import atomic_directory from pex.cache.dirs import DownloadDir from pex.common import safe_rmtree +from pex.fs.lock import FileLockStyle from pex.pep_503 import ProjectName from pex.resolve.locked_resolve import Artifact from pex.result import Error, ResultError, try_ diff --git a/pex/resolver.py b/pex/resolver.py index 64cee7819..c903b7709 100644 --- a/pex/resolver.py +++ b/pex/resolver.py @@ -22,6 +22,7 @@ from pex.dependency_configuration import DependencyConfiguration from pex.dist_metadata import DistMetadata, Distribution, Requirement, is_wheel from pex.fingerprinted_distribution import FingerprintedDistribution +from pex.fs import safe_symlink from pex.jobs import Raise, SpawnedJob, execute_parallel, iter_map_parallel from pex.network_configuration import NetworkConfiguration from pex.orderedset import OrderedSet @@ -574,7 +575,7 @@ def finalize_install(self, install_requests): source_path = os.path.join(atomic_dir.work_dir, self.request.wheel_file) start_dir = os.path.dirname(source_path) relative_target_path = os.path.relpath(self.install_chroot, start_dir) - os.symlink(relative_target_path, source_path) + safe_symlink(relative_target_path, source_path) return self._iter_resolved_distributions(install_requests, fingerprint=wheel_dir_hash) diff --git a/pex/scie/science.py b/pex/scie/science.py index af04e5f16..5541950cf 100644 --- a/pex/scie/science.py +++ b/pex/scie/science.py @@ -17,10 +17,11 @@ from pex.compatibility import shlex_quote from pex.dist_metadata import NamedEntryPoint, parse_entry_point from pex.exceptions import production_assert -from pex.executables import chmod_plus_x, is_exe +from pex.executables import chmod_plus_x from pex.fetcher import URLFetcher from pex.hashing import Sha256 from pex.layout import Layout +from pex.os import is_exe from pex.pep_440 import Version from pex.pex import PEX from pex.result import Error, try_ diff --git a/pex/sysconfig.py b/pex/sysconfig.py new file mode 100644 index 000000000..0cb72dfdd --- /dev/null +++ b/pex/sysconfig.py @@ -0,0 +1,30 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import os.path +from sysconfig import get_config_var + +from pex.os import WINDOWS + +EXE_EXTENSION = get_config_var("EXE") or "" +EXE_EXTENSIONS = ( + tuple(ext.lower() for ext in os.environ.get("PATHEXT", EXE_EXTENSION).split(os.pathsep)) + if EXE_EXTENSION + else () +) + + +def script_name(name): + # type: (str) -> str + if not EXE_EXTENSION: + return name + stem, ext = os.path.splitext(name) + return name if (ext and ext.lower() in EXE_EXTENSIONS) else name + EXE_EXTENSION + + +# TODO(John Sirois): Consider using `sysconfig.get_path("scripts", expand=False)` in combination +# with either sysconfig.get_config_vars() or Formatter().parse() to pick apart the script dir +# suffix from any base dir template. +SCRIPT_DIR = "Scripts" if WINDOWS else "bin" diff --git a/pex/venv/installer.py b/pex/venv/installer.py index f30e56fd6..fff5c3764 100644 --- a/pex/venv/installer.py +++ b/pex/venv/installer.py @@ -16,12 +16,15 @@ from pex.dist_metadata import Distribution from pex.environment import PEXEnvironment from pex.executables import chmod_plus_x +from pex.fs import safe_symlink from pex.orderedset import OrderedSet +from pex.os import WINDOWS from pex.pep_376 import InstalledWheel from pex.pep_440 import Version from pex.pep_503 import ProjectName from pex.pex import PEX from pex.result import Error +from pex.sysconfig import SCRIPT_DIR from pex.tracer import TRACER from pex.typing import TYPE_CHECKING from pex.util import CacheHelper @@ -304,7 +307,7 @@ def populate_venv_distributions( venv=venv, distributions=distributions, venv_python=provenance.target_python, - copy_mode=copy_mode, + copy_mode=CopyMode.LINK if copy_mode is CopyMode.SYMLINK and WINDOWS else copy_mode, hermetic_scripts=hermetic_scripts, top_level_source_packages=top_level_source_packages, ) @@ -345,7 +348,7 @@ def populate_venv_sources( def _iter_top_level_packages( path, # type: str - excludes=("bin", "__pycache__"), # type: Container[str] + excludes=(SCRIPT_DIR, "__pycache__"), # type: Container[str] ): # type: (...) -> Iterator[str] for name in os.listdir(path): @@ -418,11 +421,11 @@ def _populate_legacy_dist( # just 1 top-level module, we keep .pyc anchored to their associated dists when shared # and accept the cost of re-compiling top-level modules in each venv that uses them. for src, dst in iter_copytree( - src=dist.location, dst=dest_dir, exclude=("bin", "__pycache__"), copy_mode=copy_mode + src=dist.location, dst=dest_dir, exclude=(SCRIPT_DIR, "__pycache__"), copy_mode=copy_mode ): yield src, dst - dist_bin_dir = os.path.join(dist.location, "bin") + dist_bin_dir = os.path.join(dist.location, SCRIPT_DIR) if os.path.isdir(dist_bin_dir): for src, dst in iter_copytree(src=dist_bin_dir, dst=bin_dir, copy_mode=copy_mode): yield src, dst @@ -592,8 +595,8 @@ def _populate_first_party( print( "import os, sys; " "sys.path.extend(" - "entry for entry in os.environ.get('{env_var}', '').split(':') if entry" - ")".format(env_var=env_var), + "entry for entry in os.environ.get('{env_var}', '').split({pathsep!r}) if entry" + ")".format(env_var=env_var, pathsep=os.pathsep), file=fp, ) @@ -613,6 +616,7 @@ def _populate_first_party( if __name__ == "__main__": boot( shebang_python={shebang_python!r}, + venv_bin_dir={venv_bin_dir!r}, bin_path={bin_path!r}, strip_pex_env={strip_pex_env!r}, inject_env={inject_env!r}, @@ -626,6 +630,7 @@ def _populate_first_party( shebang=shebang, code=inspect.getsource(venv_pex).strip(), shebang_python=venv_python, + venv_bin_dir=SCRIPT_DIR, bin_path=bin_path, strip_pex_env=pex_info.strip_pex_env, inject_env=tuple(pex_info.inject_env.items()), @@ -636,7 +641,7 @@ def _populate_first_party( ) ) chmod_plus_x(fp.name) - os.symlink(os.path.basename(fp.name), venv.join_path("pex")) + safe_symlink(os.path.basename(fp.name), venv.join_path("pex")) with open(venv.join_path("pex-repl"), "w") as fp: fp.write( diff --git a/pex/venv/venv_pex.py b/pex/venv/venv_pex.py index 613494eb6..59066e4ff 100644 --- a/pex/venv/venv_pex.py +++ b/pex/venv/venv_pex.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from types import CodeType - from typing import Any, Dict, Iterable, List, Optional, Tuple + from typing import Any, Dict, Iterable, List, NoReturn, Optional, Tuple _PY2_EXEC_FUNCTION = """ def exec_function(ast, globals_map): @@ -40,8 +40,24 @@ def exec_function( return locals_map +if sys.platform == "win32": + + def safe_execv(argv): + # type: (List[str]) -> NoReturn + import subprocess + + sys.exit(subprocess.call(args=argv)) + +else: + + def safe_execv(argv): + # type: (List[str]) -> NoReturn + os.execv(argv[0], argv) + + def boot( shebang_python, # type: str + venv_bin_dir, # type: str bin_path, # type: str strip_pex_env, # type: bool inject_env, # type: Iterable[Tuple[str, str]] @@ -53,7 +69,7 @@ def boot( # type: (...) -> None venv_dir = os.path.abspath(os.path.dirname(__file__)) - venv_bin_dir = os.path.join(venv_dir, "bin") + venv_bin_dir = os.path.join(venv_dir, venv_bin_dir) python = os.path.join(venv_bin_dir, os.path.basename(shebang_python)) def iter_valid_venv_pythons(): @@ -96,7 +112,7 @@ def maybe_log(*message): if hermetic_re_exec: argv.append("-sE") argv.extend(sys.argv) - os.execv(python, argv) + safe_execv(argv) pex_file = os.environ.get("PEX", None) if pex_file: @@ -233,7 +249,7 @@ def maybe_log(*message): pex_script = pex_overrides.get("PEX_SCRIPT") if pex_overrides else script if pex_script: script_path = os.path.join(venv_bin_dir, pex_script) - os.execv(script_path, [script_path] + sys.argv[1:]) + safe_execv([script_path] + sys.argv[1:]) pex_interpreter = pex_overrides.get("PEX_INTERPRETER", "").lower() in ("1", "true") entry_point = None if pex_interpreter else pex_overrides.get("PEX_MODULE", entry_point) @@ -279,7 +295,7 @@ def maybe_log(*message): "Re-executing with Python interpreter options: " "cmdline={cmdline!r}".format(cmdline=" ".join(cmdline)) ) - os.execv(python, cmdline) + safe_execv(cmdline) arg = args[0] if arg == "-m": diff --git a/pex/venv/virtualenv.py b/pex/venv/virtualenv.py index de7d1f7da..887374930 100644 --- a/pex/venv/virtualenv.py +++ b/pex/venv/virtualenv.py @@ -19,9 +19,9 @@ from pex.compatibility import commonpath, get_stdout_bytes_buffer from pex.dist_metadata import Distribution, find_distributions from pex.enum import Enum -from pex.executables import is_exe from pex.executor import Executor from pex.fetcher import URLFetcher +from pex.fs import safe_symlink from pex.interpreter import ( Platlib, Purelib, @@ -31,6 +31,8 @@ create_shebang, ) from pex.orderedset import OrderedSet +from pex.os import is_exe +from pex.sysconfig import SCRIPT_DIR, script_name from pex.tracer import TRACER from pex.typing import TYPE_CHECKING, cast from pex.util import named_temporary_file @@ -380,7 +382,7 @@ def create_atomic( V=3, ) os.unlink(abs_path) - os.symlink(rel_dst, abs_path) + safe_symlink(rel_dst, abs_path) return virtualenv @@ -393,8 +395,8 @@ def __init__( # type: (...) -> None self._venv_dir = venv_dir self._custom_prompt = custom_prompt - self._bin_dir = os.path.join(venv_dir, "bin") - python_exe_path = os.path.join(self._bin_dir, python_exe_name) + self._bin_dir = os.path.join(venv_dir, SCRIPT_DIR) + python_exe_path = os.path.join(self._bin_dir, script_name(python_exe_name)) try: self._interpreter = PythonInterpreter.from_binary(python_exe_path) except PythonInterpreter.Error as e: @@ -451,7 +453,7 @@ def join_path(self, *components): def bin_path(self, *components): # type: (*str) -> str - return os.path.join(self._bin_dir, *components) + return script_name(os.path.join(self._bin_dir, *components)) @property def bin_dir(self): diff --git a/scripts/py27/lint_enum.py b/scripts/py27/lint_enum.py index 2bfe34fe6..a3f5ef722 100644 --- a/scripts/py27/lint_enum.py +++ b/scripts/py27/lint_enum.py @@ -12,6 +12,7 @@ from pex.common import pluralize from pex.interpreter_constraints import InterpreterConstraint from pex.typing import cast +from pex.venv.venv_pex import safe_execv # When running under MyPy, this will be set to True for us automatically; so we can use it as a # typing module import guard to protect Python 2 imports of typing - which is not normally available @@ -180,7 +181,7 @@ def main(): python = pythons[0] os.environ["PYTHONPATH"] = os.getcwd() - os.execv(python.binary, [python.binary] + sys.argv) + safe_execv([python.binary] + sys.argv) return lint() diff --git a/testing/__init__.py b/testing/__init__.py index f7e3b1efe..0db4fe316 100644 --- a/testing/__init__.py +++ b/testing/__init__.py @@ -23,6 +23,7 @@ from pex.enum import Enum from pex.executor import Executor from pex.interpreter import PythonInterpreter +from pex.os import LINUX, MAC from pex.pep_427 import install_wheel_chroot from pex.pex import PEX from pex.pex_builder import PEXBuilder @@ -63,8 +64,8 @@ IS_PYPY2 = IS_PYPY and sys.version_info[0] == 2 IS_PYPY3 = IS_PYPY and sys.version_info[0] == 3 NOT_CPYTHON27 = IS_PYPY or PY_VER != (2, 7) -IS_LINUX = platform.system() == "Linux" -IS_MAC = platform.system() == "Darwin" +IS_LINUX = LINUX +IS_MAC = MAC IS_X86_64 = platform.machine().lower() in ("amd64", "x86_64") IS_ARM_64 = platform.machine().lower() in ("arm64", "aarch64") IS_LINUX_X86_64 = IS_LINUX and IS_X86_64 diff --git a/testing/docker.py b/testing/docker.py index e9aaeacf8..f955d0942 100644 --- a/testing/docker.py +++ b/testing/docker.py @@ -10,7 +10,8 @@ import pytest from pex.common import safe_mkdtemp -from pex.executables import chmod_plus_x, is_exe +from pex.executables import chmod_plus_x +from pex.os import is_exe from pex.typing import TYPE_CHECKING from testing import pex_project_dir diff --git a/testing/pytest/tmp.py b/testing/pytest/tmp.py index 67c051a20..f58c4d222 100644 --- a/testing/pytest/tmp.py +++ b/testing/pytest/tmp.py @@ -13,6 +13,7 @@ from pex.common import safe_delete, safe_mkdir, safe_rmtree from pex.enum import Enum +from pex.fs import safe_symlink from pex.typing import TYPE_CHECKING if TYPE_CHECKING: @@ -99,7 +100,7 @@ def mktemp( if long_name: symlink = os.path.join(self.path, long_name) safe_delete(symlink) - os.symlink(tempdir_name, symlink) + safe_symlink(tempdir_name, symlink) return Tempdir(tempdir, symlink=symlink) except OSError as e: if e.errno == errno.EEXIST: diff --git a/tests/integration/resolve/test_issue_1907.py b/tests/integration/resolve/test_issue_1907.py index 08a6f215b..0353c8ad9 100644 --- a/tests/integration/resolve/test_issue_1907.py +++ b/tests/integration/resolve/test_issue_1907.py @@ -14,6 +14,7 @@ from pex.atomic_directory import atomic_directory from pex.common import safe_open +from pex.fs import safe_symlink from pex.pep_503 import ProjectName from pex.pex import PEX from pex.pip.version import PipVersion @@ -182,7 +183,7 @@ def test_pre_resolved_dists_offline( for dist in glob.glob(os.path.join(dists, "*")): dest_dist = os.path.join(offline, os.path.basename(dist)) if not os.path.exists(dest_dist): - os.symlink(dist, dest_dist) + safe_symlink(dist, dest_dist) pex = os.path.join(str(tmpdir), "pex") run_pex_command( diff --git a/tests/integration/scie/test_pex_scie.py b/tests/integration/scie/test_pex_scie.py index fda8c7503..826e9c59e 100644 --- a/tests/integration/scie/test_pex_scie.py +++ b/tests/integration/scie/test_pex_scie.py @@ -17,10 +17,11 @@ from pex.cache.dirs import CacheDir from pex.common import safe_open -from pex.executables import chmod_plus_x, is_exe +from pex.executables import chmod_plus_x from pex.fetcher import URLFetcher from pex.layout import Layout from pex.orderedset import OrderedSet +from pex.os import is_exe from pex.scie import SciePlatform, ScieStyle from pex.targets import LocalInterpreter from pex.typing import TYPE_CHECKING diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 62819e2b3..a24ee9750 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -20,13 +20,14 @@ from pex import targets from pex.cache.dirs import CacheDir, InterpreterDir from pex.common import environment_as, safe_mkdir, safe_open, safe_rmtree, temporary_dir, touch -from pex.compatibility import WINDOWS, commonpath +from pex.compatibility import commonpath from pex.dist_metadata import Distribution, Requirement, is_wheel -from pex.executables import is_exe from pex.fetcher import URLFetcher +from pex.fs import safe_symlink from pex.interpreter import PythonInterpreter from pex.layout import Layout from pex.network_configuration import NetworkConfiguration +from pex.os import WINDOWS, is_exe from pex.pep_427 import InstallableType from pex.pex_info import PexInfo from pex.pip.version import PipVersion @@ -341,7 +342,7 @@ def test_pex_repl_tab_complete( def test_pex_python_symlink(tmpdir): # type: (Any) -> None symlink_path = os.path.join(str(tmpdir), "python-symlink") - os.symlink(sys.executable, symlink_path) + safe_symlink(sys.executable, symlink_path) pexrc_path = os.path.join(str(tmpdir), ".pexrc") with open(pexrc_path, "w") as pexrc: pexrc.write("PEX_PYTHON=%s" % symlink_path) diff --git a/tests/integration/test_issue_2006.py b/tests/integration/test_issue_2006.py index 58d62121c..ccf1bd054 100644 --- a/tests/integration/test_issue_2006.py +++ b/tests/integration/test_issue_2006.py @@ -8,6 +8,7 @@ import pytest from pex.compatibility import PY3 +from pex.fs import safe_symlink from pex.typing import TYPE_CHECKING from testing import PY38, ensure_python_interpreter, run_pex_command @@ -45,7 +46,7 @@ def test_symlink_preserved_in_argv0( ) cowsay = os.path.join(str(tmpdir), "cowsay") - os.symlink(pex, cowsay) + safe_symlink(pex, cowsay) assert "5.0" == subprocess.check_output(args=[cowsay, "--version"]).decode("utf-8").strip(), ( "Expected the symlink used to launch this PEX to be preserved in sys.argv[0] such that " "conscript could observe it and select the cowsay console script inside the PEX for" @@ -58,7 +59,7 @@ def test_symlink_preserved_in_argv0( with open(fortune_file, "w") as fp: fp.write("Just the one") fortune = os.path.join(str(tmpdir), "fortune") - os.symlink(pex, fortune) + safe_symlink(pex, fortune) # N.B.: This fortune implementation uses print(..., file=...) without # `from __future__ import print_function`; so fails under Python 2.7 despite the fact its diff --git a/tests/integration/test_issue_2017.py b/tests/integration/test_issue_2017.py index 2c8d69b31..e524d1270 100644 --- a/tests/integration/test_issue_2017.py +++ b/tests/integration/test_issue_2017.py @@ -14,8 +14,8 @@ from pex.atomic_directory import atomic_directory from pex.common import safe_open from pex.compatibility import urlparse -from pex.executables import is_exe from pex.fetcher import URLFetcher +from pex.os import is_exe from pex.pip.version import PipVersion, PipVersionValue from pex.typing import TYPE_CHECKING from testing import IS_LINUX, run_pex_command diff --git a/tests/integration/test_issue_2413.py b/tests/integration/test_issue_2413.py index 01e9fbb80..6fd7b3dd5 100644 --- a/tests/integration/test_issue_2413.py +++ b/tests/integration/test_issue_2413.py @@ -11,7 +11,7 @@ import pytest from pex.common import safe_open -from pex.executables import is_exe +from pex.os import is_exe from pex.pep_503 import ProjectName from pex.typing import TYPE_CHECKING from pex.venv.virtualenv import InstallationChoice, Virtualenv diff --git a/tests/integration/test_issue_2572.py b/tests/integration/test_issue_2572.py index 6d2c9e0b9..acfe7dc0d 100644 --- a/tests/integration/test_issue_2572.py +++ b/tests/integration/test_issue_2572.py @@ -7,6 +7,7 @@ import shutil import subprocess +from pex.fs import safe_symlink from testing import make_env, run_pex_command from testing.pytest.tmp import Tempdir @@ -16,7 +17,7 @@ def test_symlinked_home(tmpdir): real_home = tmpdir.join("a", "b", "c") symlinked_home = tmpdir.join("lnk") - os.symlink(real_home, symlinked_home) + safe_symlink(real_home, symlinked_home) pex = tmpdir.join("pex") run_pex_command( diff --git a/tests/integration/test_lock_resolver.py b/tests/integration/test_lock_resolver.py index a5b9538da..f11033a66 100644 --- a/tests/integration/test_lock_resolver.py +++ b/tests/integration/test_lock_resolver.py @@ -16,6 +16,7 @@ from pex import dist_metadata from pex.common import open_zip, safe_open from pex.dist_metadata import ProjectNameAndVersion +from pex.fs import safe_rename from pex.interpreter import PythonInterpreter from pex.pep_440 import Version from pex.pep_503 import ProjectName @@ -379,7 +380,7 @@ def test_issue_1413_portable_find_links(tmpdir): # Now simulate using the portable lock file on another machine where the find-links repo is # mounted at a different absolute path than it was when creating the lock. moved_find_links = os.path.join(str(tmpdir), "find-links", "moved") - os.rename(original_find_links, moved_find_links) + safe_rename(original_find_links, moved_find_links) assert not os.path.exists(original_find_links) result = run_pex_command( diff --git a/tests/integration/test_locked_resolve.py b/tests/integration/test_locked_resolve.py index 4f33872df..e194cfd81 100644 --- a/tests/integration/test_locked_resolve.py +++ b/tests/integration/test_locked_resolve.py @@ -7,6 +7,7 @@ import pytest from pex import dist_metadata, resolver, targets +from pex.fs import safe_symlink from pex.pip.tool import PackageIndexConfiguration from pex.resolve.configured_resolver import ConfiguredResolver from pex.resolve.locked_resolve import LockConfiguration, LockedResolve, LockStyle @@ -134,7 +135,7 @@ def pin(local_distribution): find_links_repo = os.path.join(str(tmpdir), "find-links") os.mkdir(find_links_repo) for local_dist in downloaded.local_distributions: - os.symlink( + safe_symlink( local_dist.path, os.path.join(find_links_repo, os.path.basename(local_dist.path)) ) _, find_links_locked_resolves = create_lock( diff --git a/tests/integration/test_pep_427.py b/tests/integration/test_pep_427.py index 9e5383597..3cbefe8d2 100644 --- a/tests/integration/test_pep_427.py +++ b/tests/integration/test_pep_427.py @@ -8,7 +8,7 @@ from textwrap import dedent from pex.common import safe_open -from pex.executables import is_exe +from pex.os import is_exe from pex.pep_427 import install_wheel_interpreter from pex.pip.installation import get_pip from pex.resolve.configured_resolver import ConfiguredResolver diff --git a/tests/integration/test_shebang_length_limit.py b/tests/integration/test_shebang_length_limit.py index e701f93ec..610082634 100644 --- a/tests/integration/test_shebang_length_limit.py +++ b/tests/integration/test_shebang_length_limit.py @@ -17,6 +17,7 @@ from pex.cache.dirs import CacheDir from pex.common import safe_open, touch from pex.executables import chmod_plus_x +from pex.fs import safe_symlink from pex.typing import TYPE_CHECKING from testing import IS_PYPY, make_project, run_pex_command from testing.cli import run_pex3 @@ -130,7 +131,7 @@ def shebang_too_long(length): except (IOError, OSError) as e: if e.errno != errno.ENOENT: raise e - os.symlink("/bin/sh", sh_path) + safe_symlink("/bin/sh", sh_path) script = os.path.join(path, "script.sh") with open(script, "w") as fp: diff --git a/tests/test_atomic_directory.py b/tests/test_atomic_directory.py index c2060b264..6f53c4886 100644 --- a/tests/test_atomic_directory.py +++ b/tests/test_atomic_directory.py @@ -8,8 +8,9 @@ import pytest -from pex.atomic_directory import AtomicDirectory, FileLockStyle, _is_bsd_lock, atomic_directory +from pex.atomic_directory import AtomicDirectory, _lock_style, atomic_directory from pex.common import environment_as, temporary_dir, touch +from pex.fs.lock import FileLockStyle from pex.typing import TYPE_CHECKING try: @@ -24,24 +25,24 @@ def test_is_bsd_lock(): # type: () -> None - assert not _is_bsd_lock( + assert FileLockStyle.BSD is not _lock_style( lock_style=None ), "Expected the default lock style to be POSIX for maximum compatibility." - assert not _is_bsd_lock(lock_style=FileLockStyle.POSIX) - assert _is_bsd_lock(lock_style=FileLockStyle.BSD) + assert FileLockStyle.BSD is not _lock_style(lock_style=FileLockStyle.POSIX) + assert FileLockStyle.BSD is _lock_style(lock_style=FileLockStyle.BSD) # The hard-coded default is already POSIX, so setting the env var default changes nothing. with environment_as(_PEX_FILE_LOCK_STYLE="posix"): - assert not _is_bsd_lock(lock_style=None) - assert not _is_bsd_lock(lock_style=FileLockStyle.POSIX) - assert _is_bsd_lock(lock_style=FileLockStyle.BSD) + assert FileLockStyle.BSD is not _lock_style(lock_style=None) + assert FileLockStyle.BSD is not _lock_style(lock_style=FileLockStyle.POSIX) + assert FileLockStyle.BSD is _lock_style(lock_style=FileLockStyle.BSD) with environment_as(_PEX_FILE_LOCK_STYLE="bsd"): - assert _is_bsd_lock( + assert FileLockStyle.BSD is _lock_style( lock_style=None ), "Expected the default lock style to be taken from the environment when defined." - assert not _is_bsd_lock(lock_style=FileLockStyle.POSIX) - assert _is_bsd_lock(lock_style=FileLockStyle.BSD) + assert FileLockStyle.BSD is not _lock_style(lock_style=FileLockStyle.POSIX) + assert FileLockStyle.BSD is _lock_style(lock_style=FileLockStyle.BSD) @contextmanager diff --git a/tests/test_executables.py b/tests/test_executables.py index 4e3e0eccf..3850c4f93 100644 --- a/tests/test_executables.py +++ b/tests/test_executables.py @@ -6,33 +6,11 @@ import os from pex.common import touch -from pex.executables import chmod_plus_x, is_exe, is_python_script, is_script +from pex.executables import chmod_plus_x, is_python_script, is_script +from pex.os import is_exe from testing.pytest.tmp import Tempdir -def test_is_exe(tmpdir): - # type: (Tempdir) -> None - - all_exe = tmpdir.join("all_exe") - touch(all_exe) - chmod_plus_x(all_exe) - assert is_exe(all_exe) - - other_exe = tmpdir.join("other_exe") - touch(other_exe) - os.chmod(other_exe, 0o665) - assert not is_exe(other_exe) - - not_exe = tmpdir.join("not_exe") - touch(not_exe) - assert not is_exe(not_exe) - - exe_dir = tmpdir.join("exe_dir") - os.mkdir(exe_dir) - chmod_plus_x(exe_dir) - assert not is_exe(exe_dir) - - def test_is_script(tmpdir): # type: (Tempdir) -> None diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index e76f709ac..9ad515c5c 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -17,6 +17,7 @@ from pex.common import environment_as, safe_mkdir, safe_mkdtemp, temporary_dir, touch from pex.executables import chmod_plus_x from pex.executor import Executor +from pex.fs import safe_rename, safe_symlink from pex.interpreter import PythonInterpreter, create_shebang from pex.jobs import Job from pex.pyenv import Pyenv @@ -200,7 +201,7 @@ def test_iter_interpreter_path_filter_symlink(self, test_interpreter1, test_inte # doesn't impact this test. bin_dir = os.path.join(tmpdir, "bin_dir") os.mkdir(bin_dir) - os.symlink(test_interpreter2, os.path.join(bin_dir, "jake")) + safe_symlink(test_interpreter2, os.path.join(bin_dir, "jake")) # Verify path filtering happens before interpreter resolution, which os.path.realpaths # the interpreter binary. This supports specifying a path filter like @@ -298,13 +299,13 @@ def assert_shim_inactive(shim_name): # should be re-read and found invalid. py37_version_dir = os.path.dirname(os.path.dirname(py38)) py37_deleted = "{}.uninstalled".format(py37_version_dir) - os.rename(py37_version_dir, py37_deleted) + safe_rename(py37_version_dir, py37_deleted) try: assert_shim_inactive("python") assert_shim_inactive("python3") assert_shim_inactive("python3.8") finally: - os.rename(py37_deleted, py37_version_dir) + safe_rename(py37_deleted, py37_version_dir) assert_shim("python", py38) @@ -451,7 +452,7 @@ def macos_monterey_interpeter( chmod_plus_x(pythonwrapper) python = tmpdir_factory.mktemp("bin", request=request).join("python") - os.symlink(pythonwrapper, python) + safe_symlink(pythonwrapper, python) return python diff --git a/tests/test_os.py b/tests/test_os.py new file mode 100644 index 000000000..2327d1f72 --- /dev/null +++ b/tests/test_os.py @@ -0,0 +1,34 @@ +# Copyright 2025 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import + +import os + +from pex.common import touch +from pex.executables import chmod_plus_x +from pex.os import is_exe +from testing.pytest.tmp import Tempdir + + +def test_is_exe(tmpdir): + # type: (Tempdir) -> None + + all_exe = tmpdir.join("all_exe") + touch(all_exe) + chmod_plus_x(all_exe) + assert is_exe(all_exe) + + other_exe = tmpdir.join("other_exe") + touch(other_exe) + os.chmod(other_exe, 0o665) + assert not is_exe(other_exe) + + not_exe = tmpdir.join("not_exe") + touch(not_exe) + assert not is_exe(not_exe) + + exe_dir = tmpdir.join("exe_dir") + os.mkdir(exe_dir) + chmod_plus_x(exe_dir) + assert not is_exe(exe_dir) diff --git a/tests/test_pex.py b/tests/test_pex.py index 5a2725b60..7d7fe24f3 100644 --- a/tests/test_pex.py +++ b/tests/test_pex.py @@ -17,9 +17,11 @@ from pex import resolver from pex.common import environment_as, safe_mkdir, safe_open, temporary_dir -from pex.compatibility import PY2, WINDOWS, commonpath, to_bytes +from pex.compatibility import PY2, commonpath, to_bytes from pex.dist_metadata import Distribution +from pex.fs import safe_symlink from pex.interpreter import PythonIdentity, PythonInterpreter +from pex.os import WINDOWS from pex.pex import PEX, IsolatedSysPath from pex.pex_builder import PEXBuilder from pex.pex_info import PexInfo @@ -245,7 +247,7 @@ def test_site_libs_symlink(tmpdir): sys_path_entry = sys_path_entries[0] sys_path_entry_link = os.path.join(str(tmpdir), "lib-link") - os.symlink(sys_path_entry, sys_path_entry_link) + safe_symlink(sys_path_entry, sys_path_entry_link) with mock.patch.object(site, "getsitepackages") as mock_site_packages, mock.patch( "sys.path", new=[sys_path_entry_link] @@ -253,7 +255,7 @@ def test_site_libs_symlink(tmpdir): site_packages = os.path.join(str(tmpdir), "site-packages") os.mkdir(site_packages) site_packages_link = os.path.join(str(tmpdir), "site-packages-link") - os.symlink(site_packages, site_packages_link) + safe_symlink(site_packages, site_packages_link) mock_site_packages.return_value = [site_packages_link] isolated_sys_path = IsolatedSysPath.for_pex( diff --git a/tests/test_pex_builder.py b/tests/test_pex_builder.py index 764da40ba..32cce9dc2 100644 --- a/tests/test_pex_builder.py +++ b/tests/test_pex_builder.py @@ -15,9 +15,11 @@ from pex.cache.dirs import CacheDir from pex.common import CopyMode, open_zip, safe_open, temporary_dir, touch -from pex.compatibility import WINDOWS, commonpath +from pex.compatibility import commonpath from pex.executor import Executor +from pex.fs import safe_rename from pex.layout import Layout +from pex.os import WINDOWS from pex.pex import PEX from pex.pex_builder import Check, InvalidZipAppError, PEXBuilder from pex.pex_warnings import PEXWarning @@ -551,7 +553,7 @@ def write_zipapp(path): fp.write("\n") accum = file_too_big + ".accum" for _ in range(32): - os.rename(file_too_big, accum) + safe_rename(file_too_big, accum) with open(file_too_big, "wb") as dest: subprocess.check_call(args=["cat", accum, accum], stdout=dest.fileno()) os.unlink(accum) diff --git a/tests/test_pip.py b/tests/test_pip.py index c067d4f40..8bde56cb4 100644 --- a/tests/test_pip.py +++ b/tests/test_pip.py @@ -15,6 +15,7 @@ from pex.common import environment_as, safe_rmtree from pex.dist_metadata import Distribution, Requirement +from pex.fs import safe_symlink from pex.interpreter import PythonInterpreter from pex.jobs import Job from pex.pep_440 import Version @@ -346,7 +347,7 @@ def test_pip_pex_interpreter_venv_hash_issue_1885( _PIP.pop(installation, None) binary = current_interpreter.binary binary_link = os.path.join(str(tmpdir), "python") - os.symlink(binary, binary_link) + safe_symlink(binary, binary_link) pip_w_linked_ppp = create_pip(current_interpreter, PEX_PYTHON_PATH=binary_link) print("binary link real path resolves to: {}".format(os.path.realpath(binary_link))) venv_contents_hash = hashlib.sha1( diff --git a/tests/test_util.py b/tests/test_util.py index b27169e5e..e71dd2b54 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -9,6 +9,7 @@ import pytest from pex.common import safe_mkdir, safe_open, temporary_dir, touch +from pex.fs import safe_rename from pex.pex import PEX from pex.pex_builder import PEXBuilder from pex.typing import TYPE_CHECKING, cast @@ -76,10 +77,10 @@ def test_directory_hasher(hasher, includes_hidden_expected): fp.write("contents2") hash1 = hasher(tmp_dir) - os.rename(os.path.join(tmp_dir, "c"), os.path.join(tmp_dir, "c-renamed")) + safe_rename(os.path.join(tmp_dir, "c"), os.path.join(tmp_dir, "c-renamed")) assert hash1 != hasher(tmp_dir) - os.rename(os.path.join(tmp_dir, "c-renamed"), os.path.join(tmp_dir, "c")) + safe_rename(os.path.join(tmp_dir, "c-renamed"), os.path.join(tmp_dir, "c")) assert hash1 == hasher(tmp_dir) touch(os.path.join(tmp_dir, "c", "d", "e.pyc"))