Skip to content

Commit a75cc4f

Browse files
authored
Fix identification of venv interpreters. (#3114)
Fixes #3113
1 parent 826ef8e commit a75cc4f

File tree

5 files changed

+203
-21
lines changed

5 files changed

+203
-21
lines changed

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release Notes
22

3+
## 2.91.1
4+
5+
This release partially fixes an interpreter caching bug for CPython interpreters that have the same
6+
binary contents across patch versions with variance confined to `libpython`, other shared libraries
7+
and stdlib code.
8+
9+
* Fix identification of venv interpreters. (#3114)
10+
311
## 2.91.0
412

513
This release improves editable support by honoring editable requests when creating venvs from

pex/interpreter.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,8 +1091,12 @@ def _resolve_pyenv_shim(
10911091
return binary
10921092

10931093
@classmethod
1094-
def _spawn_from_binary_external(cls, binary):
1095-
# type: (str) -> SpawnedJob[PythonInterpreter]
1094+
def _spawn_from_binary_external(
1095+
cls,
1096+
binary, # type: str
1097+
ignore_cached=False, # type: bool
1098+
):
1099+
# type: (...) -> SpawnedJob[PythonInterpreter]
10961100

10971101
def create_interpreter(
10981102
stdout, # type: bytes
@@ -1114,6 +1118,8 @@ def create_interpreter(
11141118
return interpreter
11151119

11161120
cache_dir = InterpreterDir.create(binary)
1121+
if ignore_cached:
1122+
safe_rmtree(cache_dir)
11171123
if os.path.isfile(cache_dir.interp_info_file):
11181124
try:
11191125
with open(cache_dir.interp_info_file, "rb") as fp:
@@ -1221,8 +1227,12 @@ def hashbang_matches(fn):
12211227
return None
12221228

12231229
@classmethod
1224-
def _spawn_from_binary(cls, binary):
1225-
# type: (str) -> SpawnedJob[PythonInterpreter]
1230+
def _spawn_from_binary(
1231+
cls,
1232+
binary, # type: str
1233+
ignore_cached=False, # type: bool
1234+
):
1235+
# type: (...) -> SpawnedJob[PythonInterpreter]
12261236
canonicalized_binary = cls.canonicalize_path(binary)
12271237
if not os.path.exists(canonicalized_binary):
12281238
raise cls.InterpreterNotFound(
@@ -1231,16 +1241,17 @@ def _spawn_from_binary(cls, binary):
12311241

12321242
# N.B.: The cache is written as the last step in PythonInterpreter instance initialization.
12331243
cached_interpreter = cls._PYTHON_INTERPRETER_BY_NORMALIZED_PATH.get(canonicalized_binary)
1234-
if cached_interpreter is not None:
1244+
if cached_interpreter is not None and not ignore_cached:
12351245
return SpawnedJob.completed(cached_interpreter)
1236-
return cls._spawn_from_binary_external(canonicalized_binary)
1246+
return cls._spawn_from_binary_external(canonicalized_binary, ignore_cached=ignore_cached)
12371247

12381248
@classmethod
12391249
def from_binary(
12401250
cls,
12411251
binary, # type: str
12421252
pyenv=None, # type: Optional[Pyenv]
12431253
cwd=None, # type: Optional[str]
1254+
ignore_cached=False, # type: bool
12441255
):
12451256
# type: (...) -> PythonInterpreter
12461257
"""Create an interpreter from the given `binary`.
@@ -1250,14 +1261,19 @@ def from_binary(
12501261
Auto-detected by default.
12511262
:param cwd: The cwd to use as a base to look for python version files from. The process cwd
12521263
by default.
1264+
:param ignore_cached: If the binary has already been identified, ignore the cached
1265+
identification and re-identify.
12531266
:return: an interpreter created from the given `binary`.
12541267
"""
12551268
python = cls._resolve_pyenv_shim(binary, pyenv=pyenv, cwd=cwd)
12561269
if python is None:
12571270
raise cls.IdentificationError("The pyenv shim at {} is not active.".format(binary))
12581271

12591272
try:
1260-
return cast(PythonInterpreter, cls._spawn_from_binary(python).await_result())
1273+
return cast(
1274+
PythonInterpreter,
1275+
cls._spawn_from_binary(python, ignore_cached=ignore_cached).await_result(),
1276+
)
12611277
except Job.Error as e:
12621278
raise cls.IdentificationError("Failed to identify {}: {}".format(binary, e))
12631279

@@ -1497,20 +1513,34 @@ def iter_base_candidate_binary_paths(interpreter):
14971513
if is_exe(candidate_binary_path):
14981514
yield candidate_binary_path
14991515

1500-
def is_same_interpreter(interpreter):
1501-
# type: (PythonInterpreter) -> bool
1502-
identity = interpreter._identity
1503-
return identity.version == version and identity.abi_tag == abi_tag
1504-
15051516
resolution_path = [] # type: List[str]
15061517
base_interpreter = self
15071518
while base_interpreter.is_venv:
15081519
resolved = None # type: Optional[PythonInterpreter]
1520+
maybe_reinstalled_interpreters = [] # type: List[PythonInterpreter]
15091521
for candidate_path in iter_base_candidate_binary_paths(base_interpreter):
15101522
resolved_interpreter = self.from_binary(candidate_path)
1511-
if is_same_interpreter(resolved_interpreter):
1512-
resolved = resolved_interpreter
1513-
break
1523+
if resolved_interpreter.abi_tag == abi_tag:
1524+
if resolved_interpreter.version == version:
1525+
resolved = resolved_interpreter
1526+
break
1527+
else:
1528+
# N.B.: Different patch versions of Python can have the same `python`
1529+
# binary contents and only differ in shared libraries and the stdlib. We
1530+
# guard against that case here (i.e.: a CPython patch version upgrade or
1531+
# downgrade) by busting the cache as a last resort before failing to
1532+
# resolve a base interpreter.
1533+
#
1534+
# See: https://github.com/pex-tool/pex/issues/3113
1535+
maybe_reinstalled_interpreters.append(resolved_interpreter)
1536+
if resolved is None:
1537+
for maybe_reinstalled_interpreter in maybe_reinstalled_interpreters:
1538+
re_resolved_interpreter = self.from_binary(
1539+
maybe_reinstalled_interpreter.binary, ignore_cached=True
1540+
)
1541+
if re_resolved_interpreter.version == version:
1542+
resolved = re_resolved_interpreter
1543+
break
15141544
if resolved is None:
15151545
message = [
15161546
"Failed to resolve the base interpreter for the virtual environment at "
@@ -1528,7 +1558,7 @@ def is_same_interpreter(interpreter):
15281558
)
15291559
)
15301560
raise self.BaseInterpreterResolutionError("\n".join(message))
1531-
base_interpreter = resolved_interpreter
1561+
base_interpreter = resolved
15321562
resolution_path.append(base_interpreter.binary)
15331563
return base_interpreter
15341564

@@ -1551,6 +1581,11 @@ def free_threaded(self):
15511581
def python(self):
15521582
return self._identity.python
15531583

1584+
@property
1585+
def abi_tag(self):
1586+
# type: () -> str
1587+
return self._identity.abi_tag
1588+
15541589
@property
15551590
def version(self):
15561591
# type: () -> Tuple[int, int, int]

pex/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2015 Pex project contributors.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "2.91.0"
4+
__version__ = "2.91.1"

testing/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -641,9 +641,13 @@ def run_pyenv(
641641
)
642642

643643

644-
def ensure_python_distribution(version):
645-
# type: (str) -> PyenvPythonDistribution
646-
if version not in ALL_PY_VERSIONS:
644+
def ensure_python_distribution(
645+
version, # type: str
646+
python_version=None, # type: Optional[str]
647+
allow_adhoc_version=False, # type: bool
648+
):
649+
# type: (...) -> PyenvPythonDistribution
650+
if not allow_adhoc_version and version not in ALL_PY_VERSIONS:
647651
raise ValueError("Please constrain version to one of {}".format(ALL_PY_VERSIONS))
648652

649653
if WINDOWS and _ALL_PY_VERSIONS_TO_VERSION_INFO[version][:2] < (3, 8):
@@ -669,7 +673,7 @@ def ensure_python_distribution(version):
669673
if WINDOWS:
670674
python = os.path.join(interpreter_location, "python.exe")
671675
else:
672-
major, minor = version.split(".")[:2]
676+
major, minor = (python_version or version).split(".")[:2]
673677
python = os.path.join(
674678
interpreter_location, "bin", "python{major}.{minor}".format(major=major, minor=minor)
675679
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Copyright 2026 Pex project contributors.
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
4+
from __future__ import absolute_import
5+
6+
import glob
7+
import os.path
8+
import shutil
9+
import subprocess
10+
from typing import Tuple
11+
12+
import pytest
13+
14+
from pex.common import safe_delete
15+
from pex.util import CacheHelper
16+
from testing import (
17+
IS_LINUX_X86_64,
18+
PyenvPythonDistribution,
19+
ensure_python_distribution,
20+
make_env,
21+
run_pex_command,
22+
)
23+
from testing.pytest_utils.tmp import Tempdir
24+
25+
26+
@pytest.fixture
27+
def cowsay(tmpdir):
28+
# type: (Tempdir) -> str
29+
30+
pex_root = tmpdir.join("pex-root")
31+
cowsay = tmpdir.join("cowsay.pex")
32+
run_pex_command(
33+
args=[
34+
"--pex-root",
35+
pex_root,
36+
"--runtime-pex-root",
37+
pex_root,
38+
"cowsay<6",
39+
"-c",
40+
"cowsay",
41+
"-o",
42+
cowsay,
43+
]
44+
).assert_success()
45+
return cowsay
46+
47+
48+
def pypy_dist(
49+
pyenv_version, # type: str
50+
python_version, # type: Tuple[int, int, int]
51+
):
52+
# type: (...) -> PyenvPythonDistribution
53+
pypy_distribution = ensure_python_distribution(
54+
pyenv_version,
55+
python_version="{major}.{minor}".format(major=python_version[0], minor=python_version[1]),
56+
allow_adhoc_version=True,
57+
)
58+
assert (
59+
"{major}.{minor}.{patch}".format(
60+
major=python_version[0], minor=python_version[1], patch=python_version[2]
61+
)
62+
== subprocess.check_output(
63+
args=[
64+
pypy_distribution.binary,
65+
"-c",
66+
"import sys; print('.'.join(map(str, sys.version_info[:3])))",
67+
]
68+
)
69+
.decode("utf-8")
70+
.strip()
71+
)
72+
return pypy_distribution
73+
74+
75+
@pytest.fixture
76+
def pypy3_11_11_dist():
77+
# type: () -> PyenvPythonDistribution
78+
return pypy_dist("pypy3.11-7.3.19", python_version=(3, 11, 11))
79+
80+
81+
@pytest.fixture
82+
def pypy3_11_13_dist():
83+
# type: () -> PyenvPythonDistribution
84+
return pypy_dist("pypy3.11-7.3.20", python_version=(3, 11, 13))
85+
86+
87+
@pytest.mark.skipif(
88+
not IS_LINUX_X86_64,
89+
reason=(
90+
"We only need to test this for one known-good pair of interpreters with matching binary "
91+
"hash and differing patch versions. The pypy3.11-7.3.19 / pypy3.11-7.3.20 pair is known to "
92+
"meet this criteria for x86_64 Linux."
93+
),
94+
)
95+
def test_interpreter_upgrade_same_binary_hash(
96+
tmpdir, # type: Tempdir
97+
cowsay, # type: str
98+
pypy3_11_11_dist, # type: PyenvPythonDistribution
99+
pypy3_11_13_dist, # type: PyenvPythonDistribution
100+
):
101+
# type: (...) -> None
102+
103+
assert pypy3_11_11_dist.binary != pypy3_11_13_dist.binary
104+
assert CacheHelper.hash(pypy3_11_11_dist.binary) == CacheHelper.hash(pypy3_11_13_dist.binary)
105+
106+
pypy_311_prefix = tmpdir.join("opt", "pypy")
107+
108+
def install_pypy_311_and_create_venv(
109+
pypy_distribution, # type: PyenvPythonDistribution
110+
venv_dir, # type: str
111+
):
112+
# type: (...) -> str
113+
114+
if os.path.exists(pypy_311_prefix):
115+
shutil.move(pypy_311_prefix, pypy_311_prefix + ".sav")
116+
shutil.copytree(pypy_distribution.interpreter.prefix, pypy_311_prefix)
117+
for binary in glob.glob(os.path.join(pypy_311_prefix, "bin", "*")):
118+
if os.path.basename(binary) not in ("libpypy3.11-c.so", "pypy3.11"):
119+
safe_delete(binary)
120+
pypy_binary = os.path.join(pypy_311_prefix, "bin", "pypy3.11")
121+
venv_path = tmpdir.join(venv_dir)
122+
subprocess.check_call(args=[pypy_binary, "-m", "venv", venv_path])
123+
return os.path.join(venv_path, "bin", "python")
124+
125+
pypy3_11_11_venv_binary = install_pypy_311_and_create_venv(pypy3_11_11_dist, "pypy3_11_11.venv")
126+
assert b"| I am 3.11.11 |" in subprocess.check_output(
127+
args=[pypy3_11_11_venv_binary, cowsay, "I am 3.11.11"],
128+
env=make_env(PEX_PYTHON_PATH=pypy3_11_11_venv_binary),
129+
)
130+
131+
pypy3_11_13_venv_binary = install_pypy_311_and_create_venv(pypy3_11_13_dist, "pypy3_11_13.venv")
132+
assert b"| I am 3.11.13 |" in subprocess.check_output(
133+
args=[pypy3_11_13_venv_binary, cowsay, "I am 3.11.13"],
134+
env=make_env(PEX_PYTHON_PATH=pypy3_11_13_venv_binary),
135+
)

0 commit comments

Comments
 (0)