Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixup pex re-exec during bootstrap. #741

Merged
merged 5 commits into from
Jun 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions pex/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from pex.compatibility import string
from pex.executor import Executor
from pex.orderedset import OrderedSet
from pex.pep425tags import (
get_abbr_impl,
get_abi_tag,
Expand Down Expand Up @@ -283,7 +284,7 @@ def get(cls):
@classmethod
def all(cls, paths=None):
if paths is None:
paths = os.getenv('PATH', '').split(':')
paths = os.getenv('PATH', '').split(os.pathsep)
return cls.filter(cls.find(paths))

@classmethod
Expand Down Expand Up @@ -334,12 +335,13 @@ def from_binary(cls, binary):
extras.
:rtype: :class:`PythonInterpreter`
"""
if binary not in cls.CACHE:
if binary == sys.executable:
cls.CACHE[binary] = cls._from_binary_internal()
normalized_binary = os.path.realpath(binary)
if normalized_binary not in cls.CACHE:
if normalized_binary == os.path.realpath(sys.executable):
cls.CACHE[normalized_binary] = cls._from_binary_internal()
else:
cls.CACHE[binary] = cls._from_binary_external(binary)
return cls.CACHE[binary]
cls.CACHE[normalized_binary] = cls._from_binary_external(normalized_binary)
return cls.CACHE[normalized_binary]

@classmethod
def _matches_binary_name(cls, basefile):
Expand Down Expand Up @@ -378,7 +380,7 @@ def version_filter(version):
return (version[MAJOR] == 2 and version[MINOR] >= 7 or
version[MAJOR] == 3 and version[MINOR] >= 4)

all_versions = set(interpreter.identity.version for interpreter in pythons)
all_versions = OrderedSet(interpreter.identity.version for interpreter in pythons)
good_versions = filter(version_filter, all_versions)

for version in good_versions:
Expand Down
193 changes: 115 additions & 78 deletions pex/pex_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,122 +18,159 @@
__all__ = ('bootstrap_pex',)


def find_in_path(target_interpreter):
if os.path.exists(target_interpreter):
return target_interpreter

for directory in os.getenv('PATH', '').split(os.pathsep):
try_path = os.path.join(directory, target_interpreter)
if os.path.exists(try_path):
return try_path
def _find_pex_python(pex_python):
def try_create(try_path):
try:
return PythonInterpreter.from_binary(try_path)
except Executor.ExecutionError:
return None

interpreter = try_create(pex_python)
if interpreter:
# If the target interpreter specified in PEX_PYTHON is an existing absolute path - use it.
yield interpreter
else:
# Otherwise scan the PATH for matches:
for directory in os.getenv('PATH', '').split(os.pathsep):
try_path = os.path.join(directory, pex_python)
interpreter = try_create(try_path)
if interpreter:
yield interpreter


def find_compatible_interpreters(pex_python_path=None, compatibility_constraints=None):
def find_compatible_interpreters(path=None, compatibility_constraints=None):
"""Find all compatible interpreters on the system within the supplied constraints and use
PEX_PYTHON_PATH if it is set. If not, fall back to interpreters on $PATH.
path if it is set. If not, fall back to interpreters on $PATH.
"""
if pex_python_path:
interpreters = []
for binary in pex_python_path.split(os.pathsep):
try:
interpreters.append(PythonInterpreter.from_binary(binary))
except Executor.ExecutionError:
print("Python interpreter %s in PEX_PYTHON_PATH failed to load properly." % binary,
file=sys.stderr)
if not interpreters:
die('PEX_PYTHON_PATH was defined, but no valid interpreters could be identified. Exiting.')
interpreters = OrderedSet()
paths = None
if path:
paths = path.split(os.pathsep)
else:
# We may have been invoked with a specific interpreter not on the $PATH, make sure our
# sys.executable is included as a candidate in this case.
interpreters = OrderedSet([PythonInterpreter.get()])
# We may have been invoked with a specific interpreter, make sure our sys.executable is included
# as a candidate in this case.
interpreters.add(PythonInterpreter.get())
interpreters.update(PythonInterpreter.all(paths=paths))
return _filter_compatible_interpreters(interpreters,
compatibility_constraints=compatibility_constraints)

# Add all qualifying interpreters found in $PATH.
interpreters.update(PythonInterpreter.all())

def _filter_compatible_interpreters(interpreters, compatibility_constraints=None):
return list(
matched_interpreters(interpreters, compatibility_constraints)
if compatibility_constraints
else interpreters
)


def _select_pex_python_interpreter(target_python, compatibility_constraints=None):
target = find_in_path(target_python)

if not target:
die('Failed to find interpreter specified by PEX_PYTHON: %s' % target)
if compatibility_constraints:
pi = PythonInterpreter.from_binary(target)
if not list(matched_interpreters([pi], compatibility_constraints)):
die('Interpreter specified by PEX_PYTHON (%s) is not compatible with specified '
'interpreter constraints: %s' % (target, str(compatibility_constraints)))
if not os.path.exists(target):
die('Target interpreter specified by PEX_PYTHON %s does not exist. Exiting.' % target)
return target
def _select_pex_python_interpreter(pex_python, compatibility_constraints=None):
compatible_interpreters = _filter_compatible_interpreters(
_find_pex_python(pex_python),
compatibility_constraints=compatibility_constraints
)
if not compatible_interpreters:
die('Failed to find a compatible PEX_PYTHON={} for constraints: {}'
.format(pex_python, compatibility_constraints))
return _select_interpreter(compatible_interpreters)


def _select_interpreter(pex_python_path=None, compatibility_constraints=None):
def _select_path_interpreter(path=None, compatibility_constraints=None):
compatible_interpreters = find_compatible_interpreters(
pex_python_path=pex_python_path, compatibility_constraints=compatibility_constraints)

path=path,
compatibility_constraints=compatibility_constraints
)
if not compatible_interpreters:
die('Failed to find compatible interpreter for constraints: %s'
% str(compatibility_constraints))
# TODO: https://github.com/pantsbuild/pex/issues/430
target = min(compatible_interpreters).binary
die('Failed to find compatible interpreter on path {} for constraints: {}'
.format(path or os.getenv('PATH'), compatibility_constraints))
return _select_interpreter(compatible_interpreters)

if os.path.exists(target):
return target

def _select_interpreter(candidate_interpreters):
current_interpreter = PythonInterpreter.get()
if current_interpreter in candidate_interpreters:
# Always prefer continuing with the current interpreter when possible.
return current_interpreter
else:
# TODO: Allow the selection strategy to be parameterized:
# https://github.com/pantsbuild/pex/issues/430
return min(candidate_interpreters)

def maybe_reexec_pex(compatibility_constraints):
"""
Handle environment overrides for the Python interpreter to use when executing this pex.

def maybe_reexec_pex(compatibility_constraints=None):
"""Handle environment overrides for the Python interpreter to use when executing this pex.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a style change or does it change any presentations from help/pydocs, etc? Just curious.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a style change - I wanted to make it clear None was an acceptable input. The general implication of def maybe_reexec_pex(compatibility_constraints) is that compatibility_constraints are required.


This function supports interpreter filtering based on interpreter constraints stored in PEX-INFO
metadata. If PEX_PYTHON is set in a pexrc, it attempts to obtain the binary location of the
interpreter specified by PEX_PYTHON. If PEX_PYTHON_PATH is set, it attempts to search the path for
a matching interpreter in accordance with the interpreter constraints. If both variables are
present in a pexrc, this function gives precedence to PEX_PYTHON_PATH and errors out if no
compatible interpreters can be found on said path.
metadata. If PEX_PYTHON is set it attempts to obtain the binary location of the interpreter
specified by PEX_PYTHON. If PEX_PYTHON_PATH is set, it attempts to search the path for a matching
interpreter in accordance with the interpreter constraints. If both variables are present, this
function gives precedence to PEX_PYTHON_PATH and errors out if no compatible interpreters can be
found on said path.

If neither variable is set, we fall back to plain PEX execution using PATH searching or the
currently executing interpreter. If compatibility constraints are used, we match those constraints
against these interpreters.

:param compatibility_constraints: list of requirements-style strings that constrain the
Python interpreter to re-exec this pex with.
:param compatibility_constraints: optional list of requirements-style strings that constrain the
Python interpreter to re-exec this pex with.
"""
if os.environ.pop('SHOULD_EXIT_BOOTSTRAP_REEXEC', None):

current_interpreter = PythonInterpreter.get()

# NB: Used only for tests.
if '_PEX_EXEC_CHAIN' in os.environ:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel slightly less dirty with this tweak. Now user code will never see _PEX_EXEC_CHAIN in the environment in production.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this used somehow before? Confused about the "now" verbage.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before 4efafc2 the _PEX_EXEC_CHAIN environment variable always leaked to the environment of user code - they could see it and read it. Now (with the fix in 4efafc2), the environment variable is never exposed by the pex runtime. It must 1st be present in the enviornment to be mutated. IE: Only if you run _PEX_EXEC_CHAIN=1 ./my.pex will the variable be mutated to populate it with the interpreter invocation chain. Tests do this, users won't know to do this.

flag_or_chain = os.environ.pop('_PEX_EXEC_CHAIN')
pex_exec_chain = [] if flag_or_chain == '1' else flag_or_chain.split(os.pathsep)
pex_exec_chain.append(current_interpreter.binary)
os.environ['_PEX_EXEC_CHAIN'] = os.pathsep.join(pex_exec_chain)

current_interpreter_blessed_env_var = '_PEX_SHOULD_EXIT_BOOTSTRAP_REEXEC'
if os.environ.pop(current_interpreter_blessed_env_var, None):
# We've already been here and selected an interpreter. Continue to execution.
return

target = None
with TRACER.timed('Selecting runtime interpreter based on pexrc', V=3):
with TRACER.timed('Selecting runtime interpreter', V=3):
if ENV.PEX_PYTHON and not ENV.PEX_PYTHON_PATH:
# preserve PEX_PYTHON re-exec for backwards compatibility
# TODO: Kill this off completely in favor of PEX_PYTHON_PATH
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This something that should probably happen soon. Not sure if we have a deprecation policy around this but it is redundant with PPP and more complexity to support than necessary.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the bit needed to kick off deprecation / removal is expanding "it's redundant" in an issue. FWICT it is not simply redundant. Today I can PEX_PYTHON=python2 ... to ensure a pex runs under some python2 interpreter on any machine. I can't do that with PEX_PYTHON_PATH since its entries are full paths or path prefixes, both of which are machine specific.

# https://github.com/pantsbuild/pex/issues/431
TRACER.log('Using PEX_PYTHON={} constrained by {}'
.format(ENV.PEX_PYTHON, compatibility_constraints), V=3)
target = _select_pex_python_interpreter(ENV.PEX_PYTHON,
compatibility_constraints=compatibility_constraints)
elif ENV.PEX_PYTHON_PATH:
target = _select_interpreter(pex_python_path=ENV.PEX_PYTHON_PATH,
compatibility_constraints=compatibility_constraints)

elif compatibility_constraints:
# Apply constraints to target using regular PATH
target = _select_interpreter(compatibility_constraints=compatibility_constraints)

if target and os.path.realpath(target) != os.path.realpath(sys.executable):
cmdline = [target] + sys.argv
TRACER.log('Re-executing: cmdline="%s", sys.executable="%s", PEX_PYTHON="%s", '
'PEX_PYTHON_PATH="%s", COMPATIBILITY_CONSTRAINTS="%s"'
% (cmdline, sys.executable, ENV.PEX_PYTHON, ENV.PEX_PYTHON_PATH,
compatibility_constraints))
ENV.delete('PEX_PYTHON')
ENV.delete('PEX_PYTHON_PATH')
os.environ['SHOULD_EXIT_BOOTSTRAP_REEXEC'] = '1'
os.execve(target, cmdline, ENV.copy())
elif ENV.PEX_PYTHON_PATH or compatibility_constraints:
TRACER.log(
'Using {path} constrained by {constraints}'.format(
path='PEX_PYTHON_PATH={}'.format(ENV.PEX_PYTHON_PATH) if ENV.PEX_PYTHON_PATH else '$PATH',
constraints=compatibility_constraints
),
V=3
)
target = _select_path_interpreter(path=ENV.PEX_PYTHON_PATH,
compatibility_constraints=compatibility_constraints)
else:
TRACER.log('Using the current interpreter {} since no constraints have been specified.'
.format(sys.executable), V=3)
return

os.environ.pop('PEX_PYTHON', None)
os.environ.pop('PEX_PYTHON_PATH', None)

if target == current_interpreter:
TRACER.log('Using the current interpreter {} since it matches constraints.'
.format(sys.executable))
return

target_binary = target.binary
cmdline = [target_binary] + sys.argv
TRACER.log('Re-executing: cmdline="%s", sys.executable="%s", PEX_PYTHON="%s", '
'PEX_PYTHON_PATH="%s", COMPATIBILITY_CONSTRAINTS="%s"'
% (cmdline, sys.executable, ENV.PEX_PYTHON, ENV.PEX_PYTHON_PATH,
compatibility_constraints))

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

os.execv(target_binary, cmdline)


def _bootstrap(entry_point):
Expand Down
4 changes: 1 addition & 3 deletions pex/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,6 @@ def __init__(self, environ=None, rc=None, use_defaults=True):
def copy(self):
return self._environ.copy()

def delete(self, variable):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

self._environ.pop(variable, None)

def set(self, variable, value):
self._environ[variable] = str(value)

Expand Down Expand Up @@ -351,5 +348,6 @@ def PEX_EMIT_WARNINGS(self):
def __repr__(self):
return '{}({!r})'.format(type(self).__name__, self._environ)


# Global singleton environment
ENV = Variables()
Loading