Skip to content

Commit e33ccd6

Browse files
authored
Include PYTHONPATH in --inherit-path logic. (#765)
Previously `PEX` would inherit `PYTHONPATH` by default despite the default `--inherit-path` setting being false. This was buggy behavior running against a primary Pex goal of providing an isolated execution environment. Now, in order to use `PYTHONPATH` to ammend the `sys.path` of a running `PEX` a `PEX_INHERIT_PATH` (`pex --inherit-path=...`) of `prefer` or `fallback` must be specified. Update corresponding settings documentation and fix `PEX` to consider `PYTHONPATH` when adjusting sys.path. Fixes #707
1 parent a6bd414 commit e33ccd6

8 files changed

+206
-26
lines changed

pex/bin/pex.py

+5-6
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,11 @@ def configure_clp_pex_options(parser):
284284
default='false',
285285
action='store',
286286
choices=['false', 'fallback', 'prefer'],
287-
help='Inherit the contents of sys.path (including site-packages) running the pex. '
288-
'Possible values: false (does not inherit sys.path), '
289-
'fallback (inherits sys.path after packaged dependencies), '
290-
'prefer (inherits sys.path before packaged dependencies), '
291-
'No value (alias for prefer, for backwards compatibility). '
292-
'[Default: %default]')
287+
help='Inherit the contents of sys.path (including site-packages, user site-packages and '
288+
'PYTHONPATH) running the pex. Possible values: false (does not inherit sys.path), '
289+
'fallback (inherits sys.path after packaged dependencies), prefer (inherits sys.path '
290+
'before packaged dependencies), No value (alias for prefer, for backwards '
291+
'compatibility). [Default: %default]')
293292

294293
group.add_option(
295294
'--compile', '--no-compile',

pex/pex.py

+38
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,23 @@ def minimum_sys_modules(cls, site_libs, modules=None):
202202

203203
return new_modules
204204

205+
_PYTHONPATH = 'PYTHONPATH'
206+
_STASHED_PYTHONPATH = '_PEX_PYTHONPATH'
207+
208+
@classmethod
209+
def stash_pythonpath(cls):
210+
pythonpath = os.environ.pop(cls._PYTHONPATH, None)
211+
if pythonpath is not None:
212+
os.environ[cls._STASHED_PYTHONPATH] = pythonpath
213+
return pythonpath
214+
215+
@classmethod
216+
def unstash_pythonpath(cls):
217+
pythonpath = os.environ.pop(cls._STASHED_PYTHONPATH, None)
218+
if pythonpath is not None:
219+
os.environ[cls._PYTHONPATH] = pythonpath
220+
return pythonpath
221+
205222
@classmethod
206223
def minimum_sys_path(cls, site_libs, inherit_path):
207224
scrub_paths = OrderedSet()
@@ -229,6 +246,27 @@ def all_distribution_paths(path):
229246
TRACER.log('Scrubbing from site-packages: %s' % path)
230247

231248
scrubbed_sys_path = list(OrderedSet(sys.path) - scrub_paths)
249+
250+
pythonpath = cls.unstash_pythonpath()
251+
if pythonpath is not None:
252+
original_pythonpath = pythonpath.split(os.pathsep)
253+
user_pythonpath = list(OrderedSet(original_pythonpath) - set(sys.path))
254+
if original_pythonpath == user_pythonpath:
255+
TRACER.log('Unstashed PYTHONPATH of %s' % pythonpath, V=2)
256+
else:
257+
TRACER.log('Extracted user PYTHONPATH of %s from unstashed PYTHONPATH of %s'
258+
% (os.pathsep.join(user_pythonpath), pythonpath), V=2)
259+
260+
if inherit_path == 'false':
261+
for path in user_pythonpath:
262+
TRACER.log('Scrubbing user PYTHONPATH element: %s' % path)
263+
elif inherit_path == 'prefer':
264+
TRACER.log('Prepending user PYTHONPATH: %s' % os.pathsep.join(user_pythonpath))
265+
scrubbed_sys_path = user_pythonpath + scrubbed_sys_path
266+
elif inherit_path == 'fallback':
267+
TRACER.log('Appending user PYTHONPATH: %s' % os.pathsep.join(user_pythonpath))
268+
scrubbed_sys_path = scrubbed_sys_path + user_pythonpath
269+
232270
scrub_from_importer_cache = filter(
233271
lambda key: any(key.startswith(path) for path in scrub_paths),
234272
sys.path_importer_cache.keys())

pex/pex_bootstrapper.py

+28-10
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ def maybe_reexec_pex(compatibility_constraints=None):
128128
# We've already been here and selected an interpreter. Continue to execution.
129129
return
130130

131+
from . import pex
132+
pythonpath = pex.PEX.stash_pythonpath()
133+
if pythonpath is not None:
134+
TRACER.log('Stashed PYTHONPATH of {}'.format(pythonpath), V=2)
135+
131136
with TRACER.timed('Selecting runtime interpreter', V=3):
132137
if ENV.PEX_PYTHON and not ENV.PEX_PYTHON_PATH:
133138
# preserve PEX_PYTHON re-exec for backwards compatibility
@@ -147,25 +152,38 @@ def maybe_reexec_pex(compatibility_constraints=None):
147152
)
148153
target = _select_path_interpreter(path=ENV.PEX_PYTHON_PATH,
149154
compatibility_constraints=compatibility_constraints)
150-
else:
151-
TRACER.log('Using the current interpreter {} since no constraints have been specified.'
152-
.format(sys.executable), V=3)
155+
elif pythonpath is None:
156+
TRACER.log('Using the current interpreter {} since no constraints have been specified and '
157+
'PYTHONPATH is not set.'.format(sys.executable), V=3)
153158
return
159+
else:
160+
target = current_interpreter
154161

155162
os.environ.pop('PEX_PYTHON', None)
156163
os.environ.pop('PEX_PYTHON_PATH', None)
157164

158-
if target == current_interpreter:
159-
TRACER.log('Using the current interpreter {} since it matches constraints.'
160-
.format(sys.executable))
165+
if pythonpath is None and target == current_interpreter:
166+
TRACER.log('Using the current interpreter {} since it matches constraints and '
167+
'PYTHONPATH is not set.'.format(sys.executable))
161168
return
162169

163170
target_binary = target.binary
164171
cmdline = [target_binary] + sys.argv
165-
TRACER.log('Re-executing: cmdline="%s", sys.executable="%s", PEX_PYTHON="%s", '
166-
'PEX_PYTHON_PATH="%s", COMPATIBILITY_CONSTRAINTS="%s"'
167-
% (cmdline, sys.executable, ENV.PEX_PYTHON, ENV.PEX_PYTHON_PATH,
168-
compatibility_constraints))
172+
TRACER.log(
173+
'Re-executing: '
174+
'cmdline={cmdline!r}, '
175+
'sys.executable={python!r}, '
176+
'PEX_PYTHON={pex_python!r}, '
177+
'PEX_PYTHON_PATH={pex_python_path!r}, '
178+
'COMPATIBILITY_CONSTRAINTS={compatibility_constraints!r}'
179+
'{pythonpath}"'.format(
180+
cmdline=' '.join(cmdline),
181+
python=sys.executable,
182+
pex_python=ENV.PEX_PYTHON,
183+
pex_python_path=ENV.PEX_PYTHON_PATH,
184+
compatibility_constraints=compatibility_constraints,
185+
pythonpath=', (stashed) PYTHONPATH="{}"'.format(pythonpath) if pythonpath is not None else '')
186+
)
169187

170188
# Avoid a re-run through compatibility_constraint checking.
171189
os.environ[current_interpreter_blessed_env_var] = '1'

pex/pex_info.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ class PexInfo(object):
3333
script: string # script to execute in this pex environment
3434
# at most one of script/entry_point can be specified
3535
zip_safe: True, default False # is this pex zip safe?
36-
inherit_path: false/fallback/prefer # should this pex inherit site-packages + PYTHONPATH?
36+
inherit_path: false/fallback/prefer # should this pex inherit site-packages + user site-packages
37+
# + PYTHONPATH?
3738
ignore_errors: True, default False # should we ignore inability to resolve dependencies?
3839
always_write_cache: False # should we always write the internal cache to disk first?
3940
# this is useful if you have very large dependencies that

pex/testing.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ def write_simple_pex(td,
217217
dists=None,
218218
sources=None,
219219
coverage=False,
220-
interpreter=None):
220+
interpreter=None,
221+
pex_info=None):
221222
"""Write a pex file that optionally contains an executable entry point.
222223
223224
:param str td: temporary directory path
@@ -229,6 +230,8 @@ def write_simple_pex(td,
229230
:param bool coverage: include coverage header
230231
:param interpreter: a custom interpreter to use to build the pex
231232
:type interpreter: :class:`pex.interpreter.PythonInterpreter`
233+
:param pex_info: a custom PexInfo to use to build the pex.
234+
:type pex_info: :class:`pex.pex_info.PexInfo`
232235
"""
233236
dists = dists or []
234237
sources = sources or []
@@ -237,7 +240,8 @@ def write_simple_pex(td,
237240

238241
pb = PEXBuilder(path=td,
239242
preamble=COVERAGE_PREAMBLE if coverage else None,
240-
interpreter=interpreter)
243+
interpreter=interpreter,
244+
pex_info=pex_info)
241245

242246
for dist in dists:
243247
pb.add_dist_location(dist.location)

pex/variables.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,17 @@ def PEX_IGNORE_ERRORS(self):
176176

177177
@property
178178
def PEX_INHERIT_PATH(self):
179-
"""Boolean
179+
"""String (false|prefer|fallback)
180+
181+
Allow inheriting packages from site-packages, user site-packages and the PYTHONPATH. By default,
182+
PEX scrubs any non stdlib packages from sys.path prior to invoking the application. Using
183+
'prefer' causes PEX to shift any non-stdlib packages before the pex environment on sys.path and
184+
using 'fallback' shifts them after instead.
180185
181-
Allow inheriting packages from site-packages. By default, PEX scrubs any packages and
182-
namespace packages from sys.path prior to invoking the application. This is generally not
183-
advised, but can be used in situations when certain dependencies do not conform to standard
184-
packaging practices and thus cannot be bundled into PEX files. Default: false.
186+
Using this option is generally not advised, but can help in situations when certain dependencies
187+
do not conform to standard packaging practices and thus cannot be bundled into PEX files.
188+
189+
Default: false.
185190
"""
186191
return self._get_string('PEX_INHERIT_PATH', default='false')
187192

tests/test_integration.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -1525,7 +1525,8 @@ def test_issues_736_requirement_setup_py_with_extras():
15251525
def _assert_exec_chain(exec_chain=None,
15261526
pex_python=None,
15271527
pex_python_path=None,
1528-
interpreter_constraints=None):
1528+
interpreter_constraints=None,
1529+
pythonpath=None):
15291530

15301531
with temporary_dir() as td:
15311532
test_pex = os.path.join(td, 'test.pex')
@@ -1555,7 +1556,8 @@ def add_to_path(entry):
15551556
env = make_env(_PEX_EXEC_CHAIN=1,
15561557
PEX_INTERPRETER=1,
15571558
PEX_PYTHON=pex_python,
1558-
PEX_PYTHON_PATH=os.pathsep.join(pex_python_path) if pex_python_path else None)
1559+
PEX_PYTHON_PATH=os.pathsep.join(pex_python_path) if pex_python_path else None,
1560+
PYTHONPATH=os.pathsep.join(pythonpath) if pythonpath else None)
15591561

15601562
output = subprocess.check_output(
15611563
[sys.executable, test_pex, '-c', 'import json, os; print(json.dumps(os.environ.copy()))'],
@@ -1575,11 +1577,23 @@ def test_pex_no_reexec_no_constraints():
15751577
_assert_exec_chain()
15761578

15771579

1580+
def test_pex_reexec_no_constraints_pythonpath_present():
1581+
_assert_exec_chain(exec_chain=[os.path.realpath(sys.executable)],
1582+
pythonpath=['.'])
1583+
1584+
15781585
def test_pex_no_reexec_constraints_match_current():
15791586
current_version = '.'.join(str(component) for component in sys.version_info[0:3])
15801587
_assert_exec_chain(interpreter_constraints=['=={}'.format(current_version)])
15811588

15821589

1590+
def test_pex_reexec_constraints_match_current_pythonpath_present():
1591+
current_version = '.'.join(str(component) for component in sys.version_info[0:3])
1592+
_assert_exec_chain(exec_chain=[os.path.realpath(sys.executable)],
1593+
pythonpath=['.'],
1594+
interpreter_constraints=['=={}'.format(current_version)])
1595+
1596+
15831597
def test_pex_reexec_constraints_dont_match_current_pex_python_path():
15841598
py36_interpreter = ensure_python_interpreter(PY36)
15851599
py27_interpreter = ensure_python_interpreter(PY27)

tests/test_pex.py

+101
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import sys
77
import tempfile
88
import textwrap
9+
from collections import namedtuple
910
from contextlib import contextmanager
1011
from types import ModuleType
1112

1213
import pytest
1314

15+
from pex.common import safe_open
1416
from pex.compatibility import PY2, WINDOWS, nested, to_bytes
1517
from pex.installer import EggInstaller, WheelInstaller
1618
from pex.interpreter import PythonInterpreter
@@ -257,6 +259,105 @@ def test_pex_run():
257259
assert fake_stdout.read() == b'hellohello'
258260

259261

262+
class PythonpathIsolationTest(namedtuple('PythonpathIsolationTest',
263+
['pythonpath', 'dists', 'exe'])):
264+
265+
@staticmethod
266+
def pex_info(inherit_path):
267+
pex_info = PexInfo.default()
268+
pex_info.inherit_path = inherit_path
269+
return pex_info
270+
271+
def assert_isolation(self, inherit_path, expected_output):
272+
env = dict(PYTHONPATH=self.pythonpath)
273+
with named_temporary_file() as fake_stdout:
274+
with temporary_dir() as temp_dir:
275+
pex_builder = write_simple_pex(
276+
temp_dir,
277+
pex_info=self.pex_info(inherit_path),
278+
dists=self.dists,
279+
exe_contents=self.exe,
280+
)
281+
282+
# Test the PEX.run API.
283+
rc = PEX(pex_builder.path()).run(stdout=fake_stdout, env=env)
284+
assert rc == 0
285+
286+
fake_stdout.seek(0)
287+
assert expected_output == fake_stdout.read().decode('utf-8')
288+
289+
# Test direct PEX execution.
290+
assert expected_output == subprocess.check_output([sys.executable, pex_builder.path()],
291+
env=env).decode('utf-8')
292+
293+
294+
@pytest.fixture(scope="module")
295+
def pythonpath_isolation_test():
296+
with temporary_dir() as temp_dir:
297+
pythonpath = os.path.join(temp_dir, 'one')
298+
with safe_open(os.path.join(pythonpath, 'foo.py'), 'w') as fp:
299+
fp.write('BAR = 42')
300+
with safe_open(os.path.join(pythonpath, 'bar.py'), 'w') as fp:
301+
fp.write('FOO = 137')
302+
303+
dist_content = {
304+
'setup.py': textwrap.dedent("""
305+
from setuptools import setup
306+
307+
setup(
308+
name='foo',
309+
version='0.0.0',
310+
zip_safe=True,
311+
packages=['foo'],
312+
install_requires=[],
313+
)
314+
"""),
315+
'foo/__init__.py': 'BAR = 137',
316+
}
317+
318+
with temporary_content(dist_content) as project_dir:
319+
installer = WheelInstaller(project_dir)
320+
foo_bdist = DistributionHelper.distribution_from_path(installer.bdist())
321+
322+
exe_contents = textwrap.dedent("""
323+
import sys
324+
325+
try:
326+
import bar
327+
except ImportError:
328+
import collections
329+
bar = collections.namedtuple('bar', ['FOO'])(None)
330+
331+
import foo
332+
333+
sys.stdout.write("foo.BAR={} bar.FOO={}".format(foo.BAR, bar.FOO))
334+
""")
335+
336+
yield PythonpathIsolationTest(pythonpath=pythonpath, dists=[foo_bdist], exe=exe_contents)
337+
338+
339+
def test_pythonpath_isolation_inherit_path_false(pythonpath_isolation_test):
340+
pythonpath_isolation_test.assert_isolation(inherit_path='false',
341+
expected_output='foo.BAR=137 bar.FOO=None')
342+
# False should map to 'false'.
343+
pythonpath_isolation_test.assert_isolation(inherit_path=False,
344+
expected_output='foo.BAR=137 bar.FOO=None')
345+
346+
347+
def test_pythonpath_isolation_inherit_path_fallback(pythonpath_isolation_test):
348+
pythonpath_isolation_test.assert_isolation(inherit_path='fallback',
349+
expected_output='foo.BAR=137 bar.FOO=137')
350+
351+
352+
def test_pythonpath_isolation_inherit_path_prefer(pythonpath_isolation_test):
353+
pythonpath_isolation_test.assert_isolation(inherit_path='prefer',
354+
expected_output='foo.BAR=42 bar.FOO=137')
355+
356+
# True should map to 'prefer'.
357+
pythonpath_isolation_test.assert_isolation(inherit_path=True,
358+
expected_output='foo.BAR=42 bar.FOO=137')
359+
360+
260361
def test_pex_executable():
261362
# Tests that pex keeps executable permissions
262363
with temporary_dir() as temp_dir:

0 commit comments

Comments
 (0)