From df79f351af8d96c394e04c1a4bcb5fb27f43a3a4 Mon Sep 17 00:00:00 2001 From: John Sirois Date: Tue, 12 Jan 2021 17:32:02 -0800 Subject: [PATCH] Record original requirements in PEX-INFO. Previously we recorded the post-resolve exact requirements of all resolved distributions. This necessitated a good deal of work to recover environment markers and still left out any top-level environment markers specified in direct requirements / requirement files passed to Pex. Now we record all requirements as we received them, only filling in project name and version for local loose-source requirements in our resolve post-processing. This saves time (~O(500ms) for medium to large PEX builds) and fixes bugs. Fixes #899 --- pex/bin/pex.py | 8 +- pex/environment.py | 17 +- pex/requirements.py | 7 +- pex/resolver.py | 355 +++++++++++++++++--------------------- tests/test_environment.py | 6 +- tests/test_integration.py | 42 +++++ tests/test_resolver.py | 16 +- 7 files changed, 233 insertions(+), 218 deletions(-) diff --git a/pex/bin/pex.py b/pex/bin/pex.py index adb5805f3..a23580d32 100755 --- a/pex/bin/pex.py +++ b/pex/bin/pex.py @@ -12,7 +12,6 @@ import sys import tempfile from argparse import Action, ArgumentDefaultsHelpFormatter, ArgumentParser, ArgumentTypeError -from shlex import shlex from textwrap import TextWrapper from pex import pex_warnings @@ -935,12 +934,9 @@ def to_python_interpreter(full_path_or_basename): ) for resolved_dist in resolveds: - log( - " %s -> %s" % (resolved_dist.requirement, resolved_dist.distribution), - V=options.verbosity, - ) pex_builder.add_distribution(resolved_dist.distribution) - pex_builder.add_requirement(resolved_dist.requirement) + if resolved_dist.direct_requirement: + pex_builder.add_requirement(resolved_dist.direct_requirement) except Unsatisfiable as e: die(str(e)) diff --git a/pex/environment.py b/pex/environment.py index cf87da451..10be7cd38 100644 --- a/pex/environment.py +++ b/pex/environment.py @@ -13,7 +13,7 @@ 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.common import atomic_directory, open_zip from pex.inherit_path import InheritPath from pex.interpreter import PythonInterpreter from pex.orderedset import OrderedSet @@ -84,6 +84,10 @@ def create( return cls(requirement=requirement, required_by=required_by) +class ResolveError(Exception): + """Indicates an error resolving requirements for a PEX.""" + + class PEXEnvironment(object): class _CachingZipImporter(object): class _CachingLoader(object): @@ -434,12 +438,11 @@ def _root_requirements_iter(self, reqs): for key, requirements in reqs_by_key.items(): ranked_dists = self._available_ranked_dists_by_key.get(key) if ranked_dists is None: - # This can only happen in a multi-platform PEX where the original requirement had - # an environment marker and this environment does not satisfy that marker. - TRACER.log( - "A distribution for {} will not be resolved in this environment.".format(key) + # We've winnowed down reqs_by_key to just those requirements whose environment + # markers apply; so, we should always have an available distribution. + raise ResolveError( + "A distribution for {} could not be resolved in this environment.".format(key) ) - continue candidates = [ (ranked_dist, requirement) for requirement in requirements @@ -519,7 +522,7 @@ def _resolve(self, reqs): ) ) - die( + raise ResolveError( "Failed to execute PEX file. Needed {platform} compatible dependencies for:\n" "{items}".format(platform=self._interpreter.platform, items="\n".join(items)) ) diff --git a/pex/requirements.py b/pex/requirements.py index 0ea6e521c..b0a8b4516 100644 --- a/pex/requirements.py +++ b/pex/requirements.py @@ -303,10 +303,13 @@ def parse_requirement_from_dist( "Failed to find a project name and version from the given wheel path: " "{wheel}".format(wheel=dist) ) + project_name_and_specifier = ProjectNameAndSpecifier.from_project_name_and_version( + project_name_and_version + ) return parse_requirement_from_project_name_and_specifier( - project_name_and_version.project_name, + project_name_and_specifier.project_name, extras=extras, - specifier=SpecifierSet("=={}".format(project_name_and_version.version)), + specifier=project_name_and_specifier.specifier, marker=marker, ) diff --git a/pex/resolver.py b/pex/resolver.py index 9557321f6..d96f2db73 100644 --- a/pex/resolver.py +++ b/pex/resolver.py @@ -5,17 +5,12 @@ from __future__ import absolute_import import functools -import json import os -import subprocess import zipfile 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 from pex.jobs import Raise, SpawnedJob, execute_parallel from pex.orderedset import OrderedSet from pex.pex_info import PexInfo @@ -29,13 +24,15 @@ ) from pex.third_party.packaging.markers import Marker from pex.third_party.packaging.version import InvalidVersion, Version -from pex.third_party.pkg_resources import Distribution, Environment, Requirement +from pex.third_party.pkg_resources import Distribution, Requirement from pex.tracer import TRACER -from pex.typing import TYPE_CHECKING -from pex.util import CacheHelper +from pex.typing import TYPE_CHECKING, cast +from pex.util import CacheHelper, DistributionHelper if TYPE_CHECKING: - from typing import Iterable, Iterator, List, Optional, Tuple + from typing import Iterable, Iterator, List, Optional, Tuple, DefaultDict + + from pex.requirements import ParsedRequirement class Untranslatable(Exception): @@ -47,144 +44,52 @@ class Unsatisfiable(Exception): class InstalledDistribution( - namedtuple("InstalledDistribution", ["target", "requirement", "distribution"]) + namedtuple("InstalledDistribution", ["target", "distribution", "direct_requirement"]) ): - """A distribution target, requirement and the installed distribution that satisfies them - both.""" - - def __new__(cls, target, requirement, distribution): - assert isinstance(target, DistributionTarget) - assert isinstance(requirement, Requirement) - assert isinstance(distribution, Distribution) - return super(InstalledDistribution, cls).__new__(cls, target, requirement, distribution) + """A distribution target, and the installed distribution that satisfies it. + If installed distribution directly satisfies a user-specified requirement, that requirement is + included. + """ -# A type alias to preserve API compatibility for resolve and resolve_multi. -ResolvedDistribution = InstalledDistribution - + @classmethod + def create( + cls, + target, # type: DistributionTarget + distribution, # type: Distribution + direct_requirement=None, # type: Optional[Requirement] + ): + # type: (...) -> InstalledDistribution + return cls(target=target, distribution=distribution, direct_requirement=direct_requirement) -class DistributionRequirements(object): - class Request(namedtuple("DistributionRequirementsRequest", ["target", "distributions"])): - def spawn_calculation(self): - search_path = [dist.location for dist in self.distributions] - - program = dedent( - """ - import json - import sys - from collections import defaultdict - from pkg_resources import Environment - - - env = Environment(search_path={search_path!r}) - dependency_requirements = [] - for key in env: - for dist in env[key]: - dependency_requirements.extend(str(req) for req in dist.requires()) - json.dump(dependency_requirements, sys.stdout) - """.format( - search_path=search_path - ) - ) + @property + def target(self): + # type: () -> DistributionTarget + return cast(DistributionTarget, super(InstalledDistribution, self).target) - job = spawn_python_job( - args=["-c", program], - stdout=subprocess.PIPE, - interpreter=self.target.get_interpreter(), - expose=["setuptools"], - ) - return SpawnedJob.stdout(job=job, result_func=self._markers_by_requirement) - - @staticmethod - def _markers_by_requirement(stdout): - dependency_requirements = json.loads(stdout.decode("utf-8")) - markers_by_req_key = defaultdict(OrderedSet) - for requirement in dependency_requirements: - req = Requirement.parse(requirement) - if req.marker: - markers_by_req_key[req.key].add(req.marker) - return markers_by_req_key + @property + def distribution(self): + # type: () -> Distribution + return cast(Distribution, super(InstalledDistribution, self).distribution) - @classmethod - def merged(cls, markers_by_requirement_key_iter): - markers_by_requirement_key = defaultdict(OrderedSet) - for distribution_markers in markers_by_requirement_key_iter: - for requirement, markers in distribution_markers.items(): - markers_by_requirement_key[requirement].update(markers) - return cls(markers_by_requirement_key) - - def __init__(self, markers_by_requirement_key): - self._markers_by_requirement_key = markers_by_requirement_key - - def to_requirement(self, dist): - req = dist.as_requirement() - - # pkg_resources.Distribution.as_requirement returns requirements in one of two forms: - # 1.) project_name==version - # 2.) project_name===version - # The latter form is used whenever the distribution's version is non-standard. In those - # cases we cannot append environment markers since `===` indicates a raw version string to - # the right that should not be parsed and instead should be compared literally in full. - # See: - # + https://www.python.org/dev/peps/pep-0440/#arbitrary-equality - # + https://github.com/pantsbuild/pex/issues/940 - operator, _ = req.specs[0] - if operator == "===": - return req - - 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: - - def choose_marker(version): - # type: (str) -> str - try: - parsed_version = Version(version) - if len(parsed_version.release) > 2: - return "python_full_version" - else: - return "python_version" - except InvalidVersion: - # Versions in a version specifier can be globs like `2.7.*` which do not parse - # as valid Versions and should be matched with python_full_version. - # See: https://www.python.org/dev/peps/pep-0440/#version-matching. - return "python_full_version" - - markers.update( - Marker(python_version) - for python_version in sorted( - "{marker} {operator} {version!r}".format( - marker=choose_marker(specifier.version), - operator=specifier.operator, - version=specifier.version, - ) - for specifier in python_requires - ) - ) + @property + def direct_requirement(self): + # type: () -> Optional[Requirement] + """The user-supplied requirement that resulted in this distribution installation. - markers.update(self._markers_by_requirement_key.get(req.key, ())) + Distributions that are installed only to satisfy transitive requirements will return `None`. + """ + return cast("Optional[Requirement]", super(InstalledDistribution, self).direct_requirement) - if not markers: - return req + def with_direct_requirement(self, direct_requirement=None): + # type: (Optional[Requirement]) -> InstalledDistribution + if direct_requirement == self.direct_requirement: + return self + return self.create(self.target, self.distribution, direct_requirement=direct_requirement) - if len(markers) == 1: - marker = next(iter(markers)) - req.marker = marker - return req - # 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. 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)) +# A type alias to preserve API compatibility for resolve and resolve_multi. +ResolvedDistribution = InstalledDistribution def parsed_platform(platform=None): @@ -416,10 +321,22 @@ def dist_dir(self): return self._atomic_dir.target_dir def finalize_build(self): - # type: () -> Iterator[InstallRequest] + # type: () -> InstallRequest self._atomic_dir.finalize() - for wheel in os.listdir(self.dist_dir): - yield InstallRequest.create(self.request.target, os.path.join(self.dist_dir, wheel)) + wheels = os.listdir(self.dist_dir) + if len(wheels) != 1: + raise AssertionError( + "Build of {request} produced {count} artifacts; expected 1:\n{actual}".format( + request=self.request, + count=len(wheels), + actual="\n".join( + "{index}. {wheel}".format(index=index, wheel=wheel) + for index, wheel in enumerate(wheels) + ), + ) + ) + wheel = wheels[0] + return InstallRequest.create(self.request.target, os.path.join(self.dist_dir, wheel)) class InstallRequest(object): @@ -526,7 +443,7 @@ def install_chroot(self): return self._atomic_dir.target_dir def finalize_install(self, install_requests): - # type: (Iterable[InstallRequest]) -> Iterator[DistributionRequirements.Request] + # type: (Iterable[InstallRequest]) -> Iterator[InstalledDistribution] self._atomic_dir.finalize() # The install_chroot is keyed by the hash of the wheel file (zip) we installed. Here we add @@ -592,28 +509,17 @@ def finalize_install(self, install_requests): relative_target_path = os.path.relpath(self.install_chroot, start_dir) os.symlink(relative_target_path, source_path) - return self._iter_requirements_requests(install_requests) + return self._iter_installed_distributions(install_requests) - def _iter_requirements_requests(self, install_requests): - # type: (Iterable[InstallRequest]) -> Iterator[DistributionRequirements.Request] + def _iter_installed_distributions(self, install_requests): + # type: (Iterable[InstallRequest]) -> Iterator[InstalledDistribution] if self.is_installed: - # N.B.: Direct snip from the Environment docs: - # - # You may explicitly set `platform` (and/or `python`) to ``None`` if you - # wish to map *all* distributions, not just those compatible with the - # running platform or Python version. - # - # Since our requested target may be foreign, we make sure find all distributions - # installed by explicitly setting both `python` and `platform` to `None`. - environment = Environment(search_path=[self.install_chroot], python=None, platform=None) - - distributions = [] - for dist_project_name in environment: - distributions.extend(environment[dist_project_name]) - + distribution = DistributionHelper.distribution_from_path(self.install_chroot) + if distribution is None: + raise AssertionError("No distribution could be found for {}.".format(self)) for install_request in install_requests: - yield DistributionRequirements.Request( - target=install_request.target, distributions=distributions + yield InstalledDistribution.create( + target=install_request.target, distribution=distribution ) @@ -621,14 +527,16 @@ class BuildAndInstallRequest(object): def __init__( self, build_requests, # type: Iterable[BuildRequest] - install_requests, # type: Iterable[InstallRequest] + install_requests, # type: Iterable[InstallRequest] + direct_requirements=None, # type: Optional[Iterable[ParsedRequirement]] package_index_configuration=None, # type: Optional[PackageIndexConfiguration] cache=None, # type: Optional[str] compile=False, # type: bool ): - - self._build_requests = build_requests - self._install_requests = install_requests + # type: (...) -> None + self._build_requests = tuple(build_requests) + self._install_requests = tuple(install_requests) + self._direct_requirements = tuple(direct_requirements or ()) self._package_index_configuration = package_index_configuration self._cache = cache self._compile = compile @@ -654,7 +562,7 @@ def _categorize_build_requests( build_request.source_path, build_result.dist_dir ) ) - install_requests.extend(build_result.finalize_build()) + install_requests.append(build_result.finalize_build()) return unsatisfied_build_requests, install_requests def _spawn_wheel_build( @@ -735,7 +643,7 @@ def install_distributions( spawn_install = functools.partial(self._spawn_install, installed_wheels_dir) to_install = list(self._install_requests) - to_calculate_requirements_for = [] + installations = [] # type: List[InstalledDistribution] # 1. Build local projects and sdists. if self._build_requests: @@ -755,9 +663,51 @@ def install_distributions( error_handler=Raise(Untranslatable), max_jobs=max_parallel_jobs, ): - to_install.extend(build_result.finalize_build()) + to_install.append(build_result.finalize_build()) - # 2. Install wheels in individual chroots. + # 2. All requirements are now in wheel form: calculate any missing direct requirement + # project names from the wheel names. + with TRACER.timed( + "Calculating project names for direct requirements:" + "\n {}".format("\n ".join(map(str, self._direct_requirements))) + ): + build_requests_by_path = { + build_request.source_path: build_request for build_request in self._build_requests + } + + def iter_direct_requirements(): + # type: () -> Iterator[Requirement] + for requirement in self._direct_requirements: + if not isinstance(requirement, LocalProjectRequirement): + yield requirement.requirement + continue + + build_request = build_requests_by_path.get(requirement.path) + if build_request is None: + raise AssertionError( + "Failed to compute a project name for {requirement}. No corresponding " + "build request was found from amongst:\n{build_requests}".format( + requirement=requirement, + build_requests="\n".join( + sorted( + "{path} -> {build_request}".format( + path=path, build_request=build_request + ) + for path, build_request in build_requests_by_path.items() + ) + ), + ) + ) + install_req = build_request.result(built_wheels_dir).finalize_build() + yield requirement.as_requirement(dist=install_req.wheel_path) + + direct_requirements_by_key = defaultdict( + OrderedSet + ) # type: DefaultDict[str, OrderedSet[Requirement]] + for direct_requirement in iter_direct_requirements(): + direct_requirements_by_key[direct_requirement.key].add(direct_requirement) + + # 3. Install wheels in individual chroots. # Dedup by wheel name; e.g.: only install universal wheels once even though they'll get # downloaded / built for each interpreter or platform. @@ -774,20 +724,19 @@ def install_distributions( requests[0] for requests in install_requests_by_wheel_file.values() ] - def add_requirements_requests(install_result): + def add_installation(install_result): install_requests = install_requests_by_wheel_file[install_result.request.wheel_file] - to_calculate_requirements_for.extend(install_result.finalize_install(install_requests)) + installations.extend(install_result.finalize_install(install_requests)) with TRACER.timed( "Installing:" "\n {}".format("\n ".join(map(str, representative_install_requests))) ): - install_requests, install_results = self._categorize_install_requests( install_requests=representative_install_requests, installed_wheels_dir=installed_wheels_dir, ) for install_result in install_results: - add_requirements_requests(install_result) + add_installation(install_result) for install_result in execute_parallel( inputs=install_requests, @@ -795,41 +744,44 @@ def add_requirements_requests(install_result): error_handler=Raise(Untranslatable), max_jobs=max_parallel_jobs, ): - add_requirements_requests(install_result) + add_installation(install_result) - # 3. Calculate the final installed requirements. - with TRACER.timed( - "Calculating installed requirements for:" - "\n {}".format("\n ".join(map(str, to_calculate_requirements_for))) - ): - distribution_requirements = DistributionRequirements.merged( - execute_parallel( - inputs=to_calculate_requirements_for, - spawn_func=DistributionRequirements.Request.spawn_calculation, - error_handler=Raise(Untranslatable), - max_jobs=max_parallel_jobs, - ) - ) + if not ignore_errors: + self._check_install(installations) installed_distributions = OrderedSet() # type: OrderedSet[InstalledDistribution] - for requirements_request in to_calculate_requirements_for: - for distribution in requirements_request.distributions: - installed_distributions.add( - InstalledDistribution( - target=requirements_request.target, - requirement=distribution_requirements.to_requirement(distribution), + for installed_distribution in installations: + distribution = installed_distribution.distribution + direct_reqs = [ + req + for req in direct_requirements_by_key.get(distribution.key, ()) + if req and distribution in req + ] + if len(direct_reqs) > 1: + raise AssertionError( + "More than one direct requirement is satisfied by {distribution}:\n" + "{requirements}\n" + "This should never happen since Pip fails when more than one requirement for " + "a given project name key is supplied and applies for a given target " + "interpreter environment.".format( distribution=distribution, + requirements="\n".join( + "{index}. {direct_req}".format(index=index, direct_req=direct_req) + for index, direct_req in enumerate(direct_reqs) + ), ) ) - - if not ignore_errors: - self._check_install(installed_distributions) + installed_distributions.add( + installed_distribution.with_direct_requirement( + direct_requirement=direct_reqs[0] if direct_reqs else None + ) + ) return installed_distributions def _check_install(self, installed_distributions): # type: (Iterable[InstalledDistribution]) -> None installed_distribution_by_key = OrderedDict( - (resolved_distribution.requirement.key, resolved_distribution) + (resolved_distribution.distribution.key, resolved_distribution) for resolved_distribution in installed_distributions ) @@ -1073,6 +1025,16 @@ def resolve_multi( # resolved along with any environment markers that control which runtime environments the # requirement should be activated in. + direct_requirements = [] # type: List[ReqInfo] + if requirements: + direct_requirements.extend(parse_requirement_strings(requirements)) + if requirement_files: + fetcher = URLFetcher(network_configuration=network_configuration) + for requirement_file in requirement_files: + direct_requirements.extend( + parse_requirement_file(requirement_file, is_constraints=False, fetcher=fetcher) + ) + workspace = safe_mkdtemp() package_index_configuration = PackageIndexConfiguration.create( @@ -1107,6 +1069,7 @@ def resolve_multi( build_and_install_request = BuildAndInstallRequest( build_requests=build_requests, install_requests=install_requests, + direct_requirements=direct_requirements, package_index_configuration=package_index_configuration, cache=cache, compile=compile, diff --git a/tests/test_environment.py b/tests/test_environment.py index cdd77fcad..90d15c137 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -161,8 +161,9 @@ def main(): def add_requirements(builder, cache): # type: (PEXBuilder, str) -> None for resolved_dist in resolve(requirements, cache=cache, interpreter=builder.interpreter): - builder.add_requirement(resolved_dist.requirement) builder.add_distribution(resolved_dist.distribution) + if resolved_dist.direct_requirement: + builder.add_requirement(resolved_dist.direct_requirement) def add_wheel(builder, content): # type: (PEXBuilder, Dict[str, str]) -> None @@ -375,7 +376,8 @@ def test_activate_extras_issue_615(): # type: () -> None with yield_pex_builder() as pb: for resolved_dist in resolver.resolve(["pex[requests]==1.6.3"], interpreter=pb.interpreter): - pb.add_requirement(resolved_dist.requirement) + if resolved_dist.direct_requirement: + pb.add_requirement(resolved_dist.direct_requirement) pb.add_dist_location(resolved_dist.distribution.location) pb.set_script("pex") pb.freeze() diff --git a/tests/test_integration.py b/tests/test_integration.py index 824658db9..8472a39da 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2805,3 +2805,45 @@ def test_constraint_file_from_url(tmpdir): dist_paths.remove("fasteners-0.15-py2.py3-none-any.whl") for dist_path in dist_paths: assert dist_path.startswith(("six-", "monotonic-")) and dist_path.endswith(".whl") + + +def test_top_level_environment_markers_issues_899(tmpdir): + # type: (Any) -> None + python27 = ensure_python_interpreter(PY27) + python36 = ensure_python_interpreter(PY36) + + pex_file = os.path.join(str(tmpdir), "pex") + + requirement = "subprocess32==3.2.7; python_version<'3'" + results = run_pex_command( + args=["--python", python27, "--python", python36, requirement, "-o", pex_file] + ) + results.assert_success() + requirements = PexInfo.from_pex(pex_file).requirements + assert len(requirements) == 1 + assert Requirement.parse(requirement) == Requirement.parse(requirements.pop()) + + output, returncode = run_simple_pex( + pex_file, + args=["-c", "import subprocess32"], + interpreter=PythonInterpreter.from_binary(python27), + ) + assert 0 == returncode + + py36_interpreter = PythonInterpreter.from_binary(python36) + output, returncode = run_simple_pex( + pex_file, + args=["-c", "import subprocess"], + interpreter=py36_interpreter, + ) + assert 0 == returncode + + py36_interpreter = PythonInterpreter.from_binary(python36) + output, returncode = run_simple_pex( + pex_file, + args=["-c", "import subprocess32"], + interpreter=py36_interpreter, + ) + assert ( + 1 == returncode + ), "Expected subprocess32 to be present in the PEX file but not activated for Python 3." diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 67679af3d..cb08860ff 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -107,7 +107,7 @@ def test_resolve_cache(): assert resolved_dists1 != resolved_dists2 assert len(resolved_dists1) == 1 assert len(resolved_dists2) == 1 - assert resolved_dists1[0].requirement == resolved_dists2[0].requirement + assert resolved_dists1[0].direct_requirement == resolved_dists2[0].direct_requirement assert resolved_dists1[0].distribution.location != resolved_dists2[0].distribution.location # With a cache, each resolve should be identical. @@ -209,7 +209,8 @@ def test_resolve_extra_setup_py(): resolved_dists = local_resolve_multi(["{}[foo]".format(project1_dir)], find_links=[td]) assert {_parse_requirement(req) for req in ("project1==1.0.0", "project2==2.0.0")} == { - _parse_requirement(resolved_dist.requirement) for resolved_dist in resolved_dists + _parse_requirement(resolved_dist.distribution.as_requirement()) + for resolved_dist in resolved_dists } @@ -225,7 +226,8 @@ def test_resolve_extra_wheel(): resolved_dists = local_resolve_multi(["project1[foo]"], find_links=[td]) assert {_parse_requirement(req) for req in ("project1==1.0.0", "project2==2.0.0")} == { - _parse_requirement(resolved_dist.requirement) for resolved_dist in resolved_dists + _parse_requirement(resolved_dist.distribution.as_requirement()) + for resolved_dist in resolved_dists } @@ -350,7 +352,7 @@ def resolve_pytest(python_version, pytest_version): resolved_dists = resolve_multi( interpreters=[interpreter], requirements=["pytest=={}".format(pytest_version)] ) - project_to_version = {rd.requirement.key: rd.distribution.version for rd in resolved_dists} + project_to_version = {rd.distribution.key: rd.distribution.version for rd in resolved_dists} assert project_to_version["pytest"] == pytest_version return project_to_version @@ -519,6 +521,10 @@ def test_resolve_arbitrary_equality_issues_940(): resolved_distributions = local_resolve_multi(requirements=[dist]) assert len(resolved_distributions) == 1 - requirement = resolved_distributions[0].requirement + requirement = resolved_distributions[0].direct_requirement + assert requirement is not None, ( + "The foo requirement was direct; so the resulting resolved distribution should carry the " + "associated requirement." + ) assert [("===", "1.0.2-fba4511")] == requirement.specs assert requirement.marker is None