Skip to content

Commit 23c5046

Browse files
committed
Support iterating a venv's distributions.
Work towards pex-tool#1361.
1 parent 851666c commit 23c5046

File tree

3 files changed

+179
-5
lines changed

3 files changed

+179
-5
lines changed

pex/interpreter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,9 @@ def _spawn_from_binary(cls, binary):
802802
# type: (str) -> SpawnedJob[PythonInterpreter]
803803
canonicalized_binary = cls.canonicalize_path(binary)
804804
if not os.path.exists(canonicalized_binary):
805-
raise cls.InterpreterNotFound(canonicalized_binary)
805+
raise cls.InterpreterNotFound(
806+
"The interpreter path {} does not exist.".format(canonicalized_binary)
807+
)
806808

807809
# N.B.: The cache is written as the last step in PythonInterpreter instance initialization.
808810
cached_interpreter = cls._PYTHON_INTERPRETER_BY_NORMALIZED_PATH.get(canonicalized_binary)

pex/tools/commands/virtualenv.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import re
1010
import sys
1111
from contextlib import closing
12+
from textwrap import dedent
1213

14+
from pex import third_party
1315
from pex.common import AtomicDirectory, is_exe, safe_mkdir
1416
from pex.compatibility import get_stdout_bytes_buffer
1517
from pex.interpreter import PythonInterpreter
@@ -19,7 +21,10 @@
1921
from pex.util import named_temporary_file
2022

2123
if TYPE_CHECKING:
22-
from typing import Iterator, Optional
24+
import attr # vendor:skip
25+
from typing import Iterator, Optional, Union
26+
else:
27+
from pex.third_party import attr
2328

2429
_MIN_PIP_PYTHON_VERSION = (2, 7, 9)
2530

@@ -71,7 +76,32 @@ def _is_python_script(executable):
7176
)
7277

7378

79+
class InvalidVirtualenvError(Exception):
80+
pass
81+
82+
83+
@attr.s(frozen=True)
84+
class DistributionInfo(object):
85+
project_name = attr.ib() # type: str
86+
version = attr.ib() # type: str
87+
sys_path_entry = attr.ib() # type: str
88+
89+
7490
class Virtualenv(object):
91+
@classmethod
92+
def enclosing(cls, python):
93+
# type: (Union[str, PythonInterpreter]) -> Optional[Virtualenv]
94+
interpreter = (
95+
python
96+
if isinstance(python, PythonInterpreter)
97+
else PythonInterpreter.from_binary(python)
98+
)
99+
if not interpreter.is_venv:
100+
return None
101+
return cls(
102+
venv_dir=interpreter.prefix, python_exe_name=os.path.basename(interpreter.binary)
103+
)
104+
75105
@classmethod
76106
def create(
77107
cls,
@@ -163,9 +193,16 @@ def __init__(
163193
self._venv_dir = venv_dir
164194
self._custom_prompt = custom_prompt
165195
self._bin_dir = os.path.join(venv_dir, "bin")
166-
self._interpreter = PythonInterpreter.from_binary(
167-
os.path.join(self._bin_dir, python_exe_name)
168-
)
196+
python_exe_path = os.path.join(self._bin_dir, python_exe_name)
197+
try:
198+
self._interpreter = PythonInterpreter.from_binary(python_exe_path)
199+
except PythonInterpreter.InterpreterNotFound as e:
200+
raise InvalidVirtualenvError(
201+
"The virtualenv at {venv_dir} is not valid. Failed to load an interpreter at "
202+
"{python_exe_path}: {err}".format(
203+
venv_dir=self._venv_dir, python_exe_path=python_exe_path, err=e
204+
)
205+
)
169206
self._site_packages_dir = (
170207
os.path.join(venv_dir, "site-packages")
171208
if self._interpreter.identity.interpreter == "PyPy"
@@ -178,6 +215,13 @@ def __init__(
178215
"site-packages",
179216
)
180217
)
218+
if not os.path.isdir(self._site_packages_dir):
219+
raise InvalidVirtualenvError(
220+
"The virtualenv at {venv_dir} is not valid. The expected site-packages directory "
221+
"at {site_packages_dir} does not exist.".format(
222+
venv_dir=venv_dir, site_packages_dir=self._site_packages_dir
223+
)
224+
)
181225
self._base_bin = frozenset(_iter_files(self._bin_dir))
182226

183227
@property
@@ -219,6 +263,48 @@ def iter_executables(self):
219263
if is_exe(path):
220264
yield path
221265

266+
def iter_distributions(self):
267+
# type: () -> Iterator[DistributionInfo]
268+
""""""
269+
setuptools_path = tuple(third_party.expose(["setuptools"]))
270+
_, stdout, _ = self.interpreter.execute(
271+
args=[
272+
"-c",
273+
dedent(
274+
"""\
275+
from __future__ import print_function
276+
277+
import sys
278+
279+
280+
setuptools_path = {setuptools_path!r}
281+
sys.path.extend(setuptools_path)
282+
283+
from pkg_resources import working_set
284+
285+
286+
for dist in working_set:
287+
if dist.location in setuptools_path:
288+
continue
289+
print(
290+
"{{project_name}} {{version}} {{sys_path_entry}}".format(
291+
project_name=dist.project_name,
292+
version=dist.version,
293+
sys_path_entry=dist.location,
294+
)
295+
)
296+
""".format(
297+
setuptools_path=setuptools_path
298+
)
299+
),
300+
]
301+
)
302+
for line in stdout.splitlines():
303+
project_name, version, sys_path_entry = line.split()
304+
yield DistributionInfo(
305+
project_name=project_name, version=version, sys_path_entry=sys_path_entry
306+
)
307+
222308
def _rewrite_base_scripts(self, real_venv_dir):
223309
# type: (str) -> Iterator[str]
224310
scripts = [
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
4+
import os.path
5+
import shutil
6+
import subprocess
7+
8+
import pytest
9+
10+
from pex.pep_503 import ProjectName
11+
from pex.testing import ALL_PY_VERSIONS, ensure_python_venv
12+
from pex.tools.commands.virtualenv import DistributionInfo, InvalidVirtualenvError, Virtualenv
13+
from pex.typing import TYPE_CHECKING
14+
15+
if TYPE_CHECKING:
16+
from typing import Any, Dict
17+
18+
19+
def test_invalid(tmpdir):
20+
# type: (Any) -> None
21+
22+
with pytest.raises(InvalidVirtualenvError):
23+
Virtualenv(venv_dir=str(tmpdir))
24+
25+
venv_dir = os.path.join(str(tmpdir), "venv")
26+
Virtualenv.create(venv_dir=venv_dir)
27+
venv = Virtualenv(venv_dir=venv_dir)
28+
29+
shutil.rmtree(venv.site_packages_dir)
30+
with pytest.raises(InvalidVirtualenvError):
31+
Virtualenv(venv_dir=venv_dir)
32+
33+
34+
def test_enclosing(tmpdir):
35+
# type: (Any) -> None
36+
37+
venv_dir = os.path.join(str(tmpdir), "venv")
38+
venv = Virtualenv.create(venv_dir=venv_dir)
39+
40+
enclosing = Virtualenv.enclosing(venv.interpreter)
41+
assert enclosing is not None
42+
assert venv_dir == enclosing.venv_dir
43+
44+
enclosing = Virtualenv.enclosing(venv.interpreter.binary)
45+
assert enclosing is not None
46+
assert venv_dir == enclosing.venv_dir
47+
48+
assert Virtualenv.enclosing(venv.interpreter.resolve_base_interpreter()) is None
49+
50+
51+
def index_distributions(venv):
52+
# type: (Virtualenv) -> Dict[ProjectName, DistributionInfo]
53+
return {
54+
ProjectName(dist_info.project_name): dist_info for dist_info in venv.iter_distributions()
55+
}
56+
57+
58+
def test_iter_distributions_empty(tmpdir):
59+
# type: (Any) -> None
60+
61+
empty_venv_dir = os.path.join(str(tmpdir), "empty.venv")
62+
empty_venv = Virtualenv.create(venv_dir=empty_venv_dir)
63+
assert {} == index_distributions(empty_venv)
64+
65+
66+
@pytest.mark.parametrize("py_version", ALL_PY_VERSIONS)
67+
def test_iter_distributions(tmpdir, py_version):
68+
# type: (Any, str) -> None
69+
70+
python, pip = ensure_python_venv(py_version)
71+
72+
venv = Virtualenv.enclosing(python)
73+
assert venv is not None
74+
75+
dists = index_distributions(venv)
76+
pip_dist_info = dists.get(ProjectName("pip"))
77+
assert pip_dist_info is not None, "Expected venv to have Pip installed."
78+
assert venv.site_packages_dir == pip_dist_info.sys_path_entry
79+
assert ProjectName("cowsay") not in dists
80+
81+
subprocess.check_call(args=[pip, "install", "cowsay==4.0"])
82+
dists = index_distributions(venv)
83+
cowsay_dist_info = dists.get(ProjectName("cowsay"))
84+
assert cowsay_dist_info is not None, "Expected venv to have cowsay installed."
85+
assert "4.0" == cowsay_dist_info.version
86+
assert venv.site_packages_dir == cowsay_dist_info.sys_path_entry

0 commit comments

Comments
 (0)