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

Respect Requires-Python in PEXEnvironment. #923

Merged
merged 1 commit into from Mar 19, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 31 additions & 0 deletions pex/dist_metadata.py
@@ -0,0 +1,31 @@
# coding=utf-8
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import absolute_import

import email

from pex.third_party.packaging.specifiers import SpecifierSet
from pex.third_party.pkg_resources import DistInfoDistribution


def requires_python(dist):
"""Examines dist for `Python-Requires` metadata and returns version constraints if any.

See: https://www.python.org/dev/peps/pep-0345/#requires-python

:param dist: A distribution to check for `Python-Requires` metadata.
:type dist: :class:`pkg_resources.Distribution`
:return: The required python version specifiers.
:rtype: :class:`packaging.specifiers.SpecifierSet` or None
"""
if not dist.has_metadata(DistInfoDistribution.PKG_INFO):
return None

metadata = dist.get_metadata(DistInfoDistribution.PKG_INFO)
pkg_info = email.parser.Parser().parsestr(metadata)
python_requirement = pkg_info.get('Requires-Python')
if not python_requirement:
return None
return SpecifierSet(python_requirement)
35 changes: 25 additions & 10 deletions pex/environment.py
Expand Up @@ -11,7 +11,7 @@
import zipfile
from collections import OrderedDict, defaultdict

from pex import pex_builder, pex_warnings
from pex import dist_metadata, pex_builder, pex_warnings
from pex.bootstrap import Bootstrap
from pex.common import atomic_directory, die, open_zip
from pex.interpreter import PythonInterpreter
Expand Down Expand Up @@ -224,7 +224,14 @@ def can_add(self, dist):
except ValueError:
return False

return not self._supported_tags.isdisjoint(tags.parse_tag(wheel_tags))
if self._supported_tags.isdisjoint(tags.parse_tag(wheel_tags)):
return False

python_requires = dist_metadata.requires_python(dist)
if not python_requires:
return True

return self._interpreter.identity.version_str in python_requires

def activate(self):
if not self._activated:
Expand All @@ -235,20 +242,28 @@ def activate(self):
return self._working_set

def _resolve(self, working_set, reqs):
reqs_by_key = OrderedDict((req.key, req) for req in reqs)
unresolved_reqs = OrderedDict()
resolveds = OrderedSet()

environment = self._target_interpreter_env.copy()
environment['extra'] = list(set(itertools.chain(*(req.extras for req in reqs))))

# Resolve them one at a time so that we can figure out which ones we need to elide should
# there be an interpreter incompatibility.
for req in reqs_by_key.values():
reqs_by_key = OrderedDict()
for req in reqs:
if req.marker and not req.marker.evaluate(environment=environment):
TRACER.log('Skipping activation of `%s` due to environment marker de-selection' % req)
continue
with TRACER.timed('Resolving %s' % req, V=2):
reqs_by_key.setdefault(req.key, []).append(req)

unresolved_reqs = OrderedDict()
resolveds = OrderedSet()

# Resolve them one at a time so that we can figure out which ones we need to elide should
# there be an interpreter incompatibility.
for key, reqs in reqs_by_key.items():
with TRACER.timed('Resolving {} from {}'.format(key, reqs), V=2):
# N.B.: We resolve the bare requirement with no version specifiers since the resolve process
# used to build this pex already did so. There may be multiple distributions satisfying any
# particular key (e.g.: a Python 2 specific version and a Python 3 specific version for a
# multi-python PEX) and we want the working set to pick the most appropriate one.
req = Requirement.parse(key)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Requirement.parse() chokes on requirements that aren't of the form foo==x.y.z, e.g., URLs. So this cannot support URLs. And I know that is not a consequence of this change, it was true before, since the method that calls this one also calls Requirement.parse(), and WorkingSet.resolve() takes parsed Requirement objects.

So this comment is actually a question for my own education - do URL requirements work today? If so, how? Is this a build time/run time distinction? I have a feeling I have used them successfully since the switch to using pip.

Copy link
Member Author

@jsirois jsirois Mar 19, 2020

Choose a reason for hiding this comment

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

Yes, this is a build time / run time distinction.

These are requirements stored in a built PEX's metadata, and all of those requirements are of the form [key]==[version](; [markers]). They are this way since they're buit from the results of a resolve. That resolve does accept the myriad forms of requirement strings, but it plops out concrete dists. Those concrete dists are used to create the requirements in the PEX metadata used here to boot up at PEX runtime:
https://github.com/pantsbuild/pex/blob/a07954a1bce1cf0c023c67f36e4e37f078eb23ba/pex/resolver.py#L555-L579

Which uses:
https://github.com/pantsbuild/pex/blob/a07954a1bce1cf0c023c67f36e4e37f078eb23ba/pex/resolver.py#L96-L97

Which ues:
https://github.com/pantsbuild/pex/blob/a07954a1bce1cf0c023c67f36e4e37f078eb23ba/pex/vendor/_vendored/setuptools/pkg_resources/__init__.py#L2870-L2877

try:
resolveds.update(working_set.resolve([req], env=self))
except DistributionNotFound as e:
Expand Down
34 changes: 28 additions & 6 deletions pex/resolver.py
Expand Up @@ -11,6 +11,7 @@
from collections import OrderedDict, defaultdict, namedtuple
from textwrap import dedent

from pex import dist_metadata
from pex.common import AtomicDirectory, atomic_directory, safe_mkdtemp
from pex.distribution_target import DistributionTarget
from pex.interpreter import spawn_python_job
Expand All @@ -20,6 +21,7 @@
from pex.pip import get_pip
from pex.platforms import Platform
from pex.requirements import local_project_from_requirement, local_projects_from_requirement_file
from pex.third_party.packaging.markers import Marker
from pex.third_party.pkg_resources import Distribution, Environment, Requirement
from pex.tracer import TRACER
from pex.util import CacheHelper
Expand Down Expand Up @@ -95,7 +97,27 @@ def __init__(self, markers_by_requirement_key):

def to_requirement(self, dist):
req = dist.as_requirement()
markers = self._markers_by_requirement_key.get(req.key)

markers = OrderedSet()

# Here we map any wheel python requirement to the equivalent environment marker:
# See:
# + https://www.python.org/dev/peps/pep-0345/#requires-python
# + https://www.python.org/dev/peps/pep-0508/#environment-markers
python_requires = dist_metadata.requires_python(dist)
if python_requires:
markers.update(
Marker(python_version)
for python_version in sorted(
'python_version {operator} {version!r}'.format(
operator=specifier.operator,
version=specifier.version
) for specifier in python_requires
)
)

markers.update(self._markers_by_requirement_key.get(req.key, ()))

if not markers:
return req

Expand All @@ -104,12 +126,12 @@ def to_requirement(self, dist):
req.marker = marker
return req

# Here we have a resolve with multiple paths to the dependency represented by dist. At least
# We may have resolved with multiple paths to the dependency represented by dist and at least
# two of those paths had (different) conditional requirements for dist based on environment
# marker predicates. Since the pip resolve succeeded, the implication is that the environment
# markers are compatible; i.e.: their intersection selects the target interpreter. Here we
# make that intersection explicit.
# See: https://www.python.org/dev/peps/pep-0496/#micro-language
# marker predicates. In that case, since the pip resolve succeeded, the implication is that the
# environment markers are compatible; i.e.: their intersection selects the target interpreter.
# Here we make that intersection explicit.
# See: https://www.python.org/dev/peps/pep-0508/#grammar
marker = ' and '.join('({})'.format(marker) for marker in markers)
return Requirement.parse('{}; {}'.format(req, marker))

Expand Down
35 changes: 35 additions & 0 deletions tests/test_integration.py
Expand Up @@ -1605,3 +1605,38 @@ def test_trusted_host_handling():
python=python
)
results.assert_success()


def test_issues_898():
python27 = ensure_python_interpreter(PY27)
python36 = ensure_python_interpreter(PY36)
with temporary_dir() as td:
src_dir = os.path.join(td, 'src')
with safe_open(os.path.join(src_dir, 'test_issues_898.py'), 'w') as fp:
fp.write(dedent("""
import zipp

print(zipp.__file__)
"""))

pex_file = os.path.join(td, 'zipp.pex')

results = run_pex_command(
args=[
'--python={}'.format(python27),
'--python={}'.format(python36),
'zipp>=1,<=3.1.0',
'--sources-directory={}'.format(src_dir),
'--entry-point=test_issues_898',
'-o', pex_file
],
)
results.assert_success()

pex_root = os.path.realpath(os.path.join(td, 'pex_root'))
for python in python27, python36:
output = subprocess.check_output([python, pex_file], env=make_env(PEX_ROOT=pex_root))
zipp_location = os.path.realpath(output.decode('utf-8').strip())
assert zipp_location.startswith(pex_root), (
'Failed to import zipp from {} under {}'.format(pex_file, python)
)