Skip to content

Commit 1acecbe

Browse files
authored
Merge pull request #6373 from ByteB4rb1e/bugfix/5978
Refactor virtualenv `bin/` / `Scripts/` path resolution using `sysconfig` mechanism
2 parents 29b4197 + 74e6f63 commit 1acecbe

File tree

6 files changed

+85
-26
lines changed

6 files changed

+85
-26
lines changed

news/6737.bugfix.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Improved virtualenv scripts path resolution
2+
3+
## Summary
4+
5+
This PR refactors the logic for determining virtual environment script paths
6+
by leveraging ``sysconfig``'s built-in mechanisms. By removing
7+
platform-dependent logic, ``pipenv`` now offers enhanced compatibility with
8+
POSIX-like environments, including Cygwin and MinGW. The fix also mitigates
9+
execution inconsistencies in non-native Windows environments, improving
10+
portability across platforms.
11+
12+
## Motivation
13+
14+
The original logic for determining the scripts path was unable to handle the
15+
deviations of MSYS2 MinGW CPython identifying as ``nt`` platform, yet using a
16+
POSIX ``{base}/bin`` path, instead of ``{base}/Scripts``.
17+
18+
## Changes
19+
20+
Removed custom logic for determining virtualenv scripts path in favor of
21+
retrieving the basename of the path string returned by
22+
``sysconfig.get_path('scripts')```.

pipenv/environment.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from pipenv.utils.indexes import prepare_pip_source_args
2727
from pipenv.utils.processes import subprocess_run
2828
from pipenv.utils.shell import temp_environ
29+
from pipenv.utils.virtualenv import virtualenv_scripts_dir
2930
from pipenv.vendor.importlib_metadata.compat.py39 import normalized_name
3031
from pipenv.vendor.pythonfinder.utils import is_in_path
3132

@@ -246,16 +247,12 @@ def script_basedir(self) -> str:
246247
@property
247248
def python(self) -> str:
248249
"""Path to the environment python"""
249-
if self._python is not None:
250-
return self._python
251-
if os.name == "nt" and not self.is_venv:
252-
py = Path(self.prefix).joinpath("python").absolute().as_posix()
253-
else:
254-
py = Path(self.script_basedir).joinpath("python").absolute().as_posix()
255-
if not py:
256-
py = Path(sys.executable).as_posix()
257-
self._python = py
258-
return py
250+
if self._python is None:
251+
self._python = (
252+
(virtualenv_scripts_dir(self.prefix) / "python").absolute().as_posix()
253+
)
254+
255+
return self._python
259256

260257
@cached_property
261258
def sys_path(self) -> list[str]:

pipenv/project.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
system_which,
7070
)
7171
from pipenv.utils.toml import cleanup_toml, convert_toml_outline_tables
72+
from pipenv.utils.virtualenv import virtualenv_scripts_dir
7273
from pipenv.vendor import plette, tomlkit
7374

7475
try:
@@ -411,11 +412,19 @@ def is_venv_in_project(self) -> bool:
411412
@property
412413
def virtualenv_exists(self) -> bool:
413414
venv_path = Path(self.virtualenv_location)
415+
416+
scripts_dir = self.virtualenv_scripts_location
417+
414418
if venv_path.exists():
415-
if os.name == "nt":
416-
activate_path = venv_path / "Scripts" / "activate.bat"
419+
420+
# existence of active.bat is dependent on the platform path prefix
421+
# scheme, not platform itself. This handles special cases such as
422+
# Cygwin/MinGW identifying as 'nt' platform, yet preferring a
423+
# 'posix' path prefix scheme.
424+
if scripts_dir.name == "Scripts":
425+
activate_path = scripts_dir / "activate.bat"
417426
else:
418-
activate_path = venv_path / "bin" / "activate"
427+
activate_path = scripts_dir / "activate"
419428
return activate_path.is_file()
420429

421430
return False
@@ -612,6 +621,10 @@ def virtualenv_src_location(self) -> Path:
612621
loc.mkdir(parents=True, exist_ok=True)
613622
return loc
614623

624+
@property
625+
def virtualenv_scripts_location(self) -> Path:
626+
return virtualenv_scripts_dir(self.virtualenv_location)
627+
615628
@property
616629
def download_location(self) -> Path:
617630
if self._download_location is None:
@@ -1422,10 +1435,10 @@ def proper_case_section(self, section):
14221435
def finders(self):
14231436
from .vendor.pythonfinder import Finder
14241437

1425-
scripts_dirname = "Scripts" if os.name == "nt" else "bin"
1426-
scripts_dir = Path(self.virtualenv_location) / scripts_dirname
14271438
finders = [
1428-
Finder(path=str(scripts_dir), global_search=gs, system=False)
1439+
Finder(
1440+
path=str(self.virtualenv_scripts_location), global_search=gs, system=False
1441+
)
14291442
for gs in (False, True)
14301443
]
14311444
return finders
@@ -1463,12 +1476,14 @@ def _which(self, command, location=None, allow_global=False):
14631476
is_python = command in ("python", Path(sys.executable).name, version_str)
14641477

14651478
if not allow_global:
1479+
scripts_location = virtualenv_scripts_dir(location_path)
1480+
14661481
if os.name == "nt":
1467-
p = find_windows_executable(str(location_path / "Scripts"), command)
1482+
p = find_windows_executable(str(scripts_location), command)
14681483
# Convert to Path object if it's a string
14691484
p = Path(p) if isinstance(p, str) else p
14701485
else:
1471-
p = location_path / "bin" / command
1486+
p = scripts_location / command
14721487
elif is_python:
14731488
p = Path(sys.executable)
14741489
else:

pipenv/routines/shell.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pipenv.utils.project import ensure_project
77
from pipenv.utils.shell import cmd_list_to_shell, system_which
8+
from pipenv.utils.virtualenv import virtualenv_scripts_dir
89
from pipenv.vendor import click
910

1011

@@ -60,7 +61,6 @@ def do_run(project, command, args, python=False, pypi_mirror=None):
6061
6162
Args are appended to the command in [scripts] section of project if found.
6263
"""
63-
from pathlib import Path
6464

6565
from pipenv.cmdparse import ScriptEmptyError
6666

@@ -79,12 +79,7 @@ def do_run(project, command, args, python=False, pypi_mirror=None):
7979
# Get the exact string representation of virtualenv_location
8080
virtualenv_location = str(project.virtualenv_location)
8181

82-
# Use pathlib for path construction but convert back to string
83-
from pathlib import Path
84-
85-
virtualenv_path = Path(virtualenv_location)
86-
bin_dir = "Scripts" if os.name == "nt" else "bin"
87-
new_path = str(virtualenv_path / bin_dir)
82+
new_path = str(virtualenv_scripts_dir(virtualenv_location))
8883

8984
# Update PATH
9085
paths = path.split(os.pathsep)

pipenv/utils/virtualenv.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
import shutil
55
import sys
6+
import sysconfig
67
from pathlib import Path
78

89
from pipenv import environments, exceptions
@@ -13,6 +14,20 @@
1314
from pipenv.utils.shell import find_python, shorten_path
1415

1516

17+
def virtualenv_scripts_dir(b):
18+
"""returns a system-dependent scripts path
19+
20+
POSIX environments (including Cygwin/MinGW64) will result in
21+
`{base}/bin/`, native Windows environments will result in
22+
`{base}/Scripts/`.
23+
24+
:param b: base path
25+
:type b: str
26+
:returns: pathlib.Path
27+
"""
28+
return Path(f"{b}/{Path(sysconfig.get_path('scripts')).name}")
29+
30+
1631
def warn_in_virtualenv(project):
1732
# Only warn if pipenv isn't already active.
1833
if environments.is_in_virtualenv() and not project.s.is_quiet():

tests/unit/test_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import os
2+
import sys
23
from unittest import mock
34

45
import pytest
56

67
from pipenv.exceptions import PipenvUsageError
7-
from pipenv.utils import dependencies, indexes, internet, shell, toml
8+
from pipenv.utils import dependencies, indexes, internet, shell, toml, virtualenv
89

910
# Pipfile format <-> requirements.txt format.
1011
DEP_PIP_PAIRS = [
@@ -547,3 +548,17 @@ def test_is_env_truthy_does_not_exisxt(self, monkeypatch):
547548
name = "ZZZ"
548549
monkeypatch.delenv(name, raising=False)
549550
assert shell.is_env_truthy(name) is False
551+
552+
@pytest.mark.utils
553+
# substring search in version handles special-case of MSYS2 MinGW CPython
554+
# https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-python/0017-sysconfig-treat-MINGW-builds-as-POSIX-builds.patch#L24
555+
@pytest.mark.skipif(os.name != "nt" or "GCC" in sys.version, reason="Windows test only")
556+
def test_virtualenv_scripts_dir_nt(self):
557+
"""
558+
"""
559+
assert str(virtualenv.virtualenv_scripts_dir('foobar')) == 'foobar\\Scripts'
560+
561+
@pytest.mark.utils
562+
@pytest.mark.skipif(os.name == "nt" and "GCC" not in sys.version, reason="POSIX test only")
563+
def test_virtualenv_scripts_dir_posix(self):
564+
assert str(virtualenv.virtualenv_scripts_dir('foobar')) == 'foobar/bin'

0 commit comments

Comments
 (0)