601 lines
24 KiB
Python
601 lines
24 KiB
Python
"""Dependency Resolution
|
|
|
|
The dependency resolution in pip is performed as follows:
|
|
|
|
for top-level requirements:
|
|
a. only one spec allowed per project, regardless of conflicts or not.
|
|
otherwise a "double requirement" exception is raised
|
|
b. they override sub-dependency requirements.
|
|
for sub-dependencies
|
|
a. "first found, wins" (where the order is breadth first)
|
|
"""
|
|
|
|
# The following comment should be removed at some point in the future.
|
|
# mypy: strict-optional=False
|
|
|
|
import logging
|
|
import sys
|
|
from collections import defaultdict
|
|
from itertools import chain
|
|
from typing import DefaultDict, Iterable, List, Optional, Set, Tuple
|
|
|
|
from pip._vendor.packaging import specifiers
|
|
from pip._vendor.packaging.requirements import Requirement
|
|
|
|
from pip._internal.cache import WheelCache
|
|
from pip._internal.exceptions import (
|
|
BestVersionAlreadyInstalled,
|
|
DistributionNotFound,
|
|
HashError,
|
|
HashErrors,
|
|
InstallationError,
|
|
NoneMetadataError,
|
|
UnsupportedPythonVersion,
|
|
)
|
|
from pip._internal.index.package_finder import PackageFinder
|
|
from pip._internal.metadata import BaseDistribution
|
|
from pip._internal.models.link import Link
|
|
from pip._internal.models.wheel import Wheel
|
|
from pip._internal.operations.prepare import RequirementPreparer
|
|
from pip._internal.req.req_install import (
|
|
InstallRequirement,
|
|
check_invalid_constraint_type,
|
|
)
|
|
from pip._internal.req.req_set import RequirementSet
|
|
from pip._internal.resolution.base import BaseResolver, InstallRequirementProvider
|
|
from pip._internal.utils import compatibility_tags
|
|
from pip._internal.utils.compatibility_tags import get_supported
|
|
from pip._internal.utils.direct_url_helpers import direct_url_from_link
|
|
from pip._internal.utils.logging import indent_log
|
|
from pip._internal.utils.misc import normalize_version_info
|
|
from pip._internal.utils.packaging import check_requires_python
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DiscoveredDependencies = DefaultDict[str, List[InstallRequirement]]
|
|
|
|
|
|
def _check_dist_requires_python(
|
|
dist: BaseDistribution,
|
|
version_info: Tuple[int, int, int],
|
|
ignore_requires_python: bool = False,
|
|
) -> None:
|
|
"""
|
|
Check whether the given Python version is compatible with a distribution's
|
|
"Requires-Python" value.
|
|
|
|
:param version_info: A 3-tuple of ints representing the Python
|
|
major-minor-micro version to check.
|
|
:param ignore_requires_python: Whether to ignore the "Requires-Python"
|
|
value if the given Python version isn't compatible.
|
|
|
|
:raises UnsupportedPythonVersion: When the given Python version isn't
|
|
compatible.
|
|
"""
|
|
# This idiosyncratically converts the SpecifierSet to str and let
|
|
# check_requires_python then parse it again into SpecifierSet. But this
|
|
# is the legacy resolver so I'm just not going to bother refactoring.
|
|
try:
|
|
requires_python = str(dist.requires_python)
|
|
except FileNotFoundError as e:
|
|
raise NoneMetadataError(dist, str(e))
|
|
try:
|
|
is_compatible = check_requires_python(
|
|
requires_python,
|
|
version_info=version_info,
|
|
)
|
|
except specifiers.InvalidSpecifier as exc:
|
|
logger.warning(
|
|
"Package %r has an invalid Requires-Python: %s", dist.raw_name, exc
|
|
)
|
|
return
|
|
|
|
if is_compatible:
|
|
return
|
|
|
|
version = ".".join(map(str, version_info))
|
|
if ignore_requires_python:
|
|
logger.debug(
|
|
"Ignoring failed Requires-Python check for package %r: %s not in %r",
|
|
dist.raw_name,
|
|
version,
|
|
requires_python,
|
|
)
|
|
return
|
|
|
|
raise UnsupportedPythonVersion(
|
|
"Package {!r} requires a different Python: {} not in {!r}".format(
|
|
dist.raw_name, version, requires_python
|
|
)
|
|
)
|
|
|
|
|
|
class Resolver(BaseResolver):
|
|
"""Resolves which packages need to be installed/uninstalled to perform \
|
|
the requested operation without breaking the requirements of any package.
|
|
"""
|
|
|
|
_allowed_strategies = {"eager", "only-if-needed", "to-satisfy-only"}
|
|
|
|
def __init__(
|
|
self,
|
|
preparer: RequirementPreparer,
|
|
finder: PackageFinder,
|
|
wheel_cache: Optional[WheelCache],
|
|
make_install_req: InstallRequirementProvider,
|
|
use_user_site: bool,
|
|
ignore_dependencies: bool,
|
|
ignore_installed: bool,
|
|
ignore_requires_python: bool,
|
|
force_reinstall: bool,
|
|
upgrade_strategy: str,
|
|
py_version_info: Optional[Tuple[int, ...]] = None,
|
|
) -> None:
|
|
super().__init__()
|
|
assert upgrade_strategy in self._allowed_strategies
|
|
|
|
if py_version_info is None:
|
|
py_version_info = sys.version_info[:3]
|
|
else:
|
|
py_version_info = normalize_version_info(py_version_info)
|
|
|
|
self._py_version_info = py_version_info
|
|
|
|
self.preparer = preparer
|
|
self.finder = finder
|
|
self.wheel_cache = wheel_cache
|
|
|
|
self.upgrade_strategy = upgrade_strategy
|
|
self.force_reinstall = force_reinstall
|
|
self.ignore_dependencies = ignore_dependencies
|
|
self.ignore_installed = ignore_installed
|
|
self.ignore_requires_python = ignore_requires_python
|
|
self.use_user_site = use_user_site
|
|
self._make_install_req = make_install_req
|
|
|
|
self._discovered_dependencies: DiscoveredDependencies = defaultdict(list)
|
|
|
|
def resolve(
|
|
self, root_reqs: List[InstallRequirement], check_supported_wheels: bool
|
|
) -> RequirementSet:
|
|
"""Resolve what operations need to be done
|
|
|
|
As a side-effect of this method, the packages (and their dependencies)
|
|
are downloaded, unpacked and prepared for installation. This
|
|
preparation is done by ``pip.operations.prepare``.
|
|
|
|
Once PyPI has static dependency metadata available, it would be
|
|
possible to move the preparation to become a step separated from
|
|
dependency resolution.
|
|
"""
|
|
requirement_set = RequirementSet(check_supported_wheels=check_supported_wheels)
|
|
for req in root_reqs:
|
|
if req.constraint:
|
|
check_invalid_constraint_type(req)
|
|
self._add_requirement_to_set(requirement_set, req)
|
|
|
|
# Actually prepare the files, and collect any exceptions. Most hash
|
|
# exceptions cannot be checked ahead of time, because
|
|
# _populate_link() needs to be called before we can make decisions
|
|
# based on link type.
|
|
discovered_reqs: List[InstallRequirement] = []
|
|
hash_errors = HashErrors()
|
|
for req in chain(requirement_set.all_requirements, discovered_reqs):
|
|
try:
|
|
discovered_reqs.extend(self._resolve_one(requirement_set, req))
|
|
except HashError as exc:
|
|
exc.req = req
|
|
hash_errors.append(exc)
|
|
|
|
if hash_errors:
|
|
raise hash_errors
|
|
|
|
return requirement_set
|
|
|
|
def _add_requirement_to_set(
|
|
self,
|
|
requirement_set: RequirementSet,
|
|
install_req: InstallRequirement,
|
|
parent_req_name: Optional[str] = None,
|
|
extras_requested: Optional[Iterable[str]] = None,
|
|
) -> Tuple[List[InstallRequirement], Optional[InstallRequirement]]:
|
|
"""Add install_req as a requirement to install.
|
|
|
|
:param parent_req_name: The name of the requirement that needed this
|
|
added. The name is used because when multiple unnamed requirements
|
|
resolve to the same name, we could otherwise end up with dependency
|
|
links that point outside the Requirements set. parent_req must
|
|
already be added. Note that None implies that this is a user
|
|
supplied requirement, vs an inferred one.
|
|
:param extras_requested: an iterable of extras used to evaluate the
|
|
environment markers.
|
|
:return: Additional requirements to scan. That is either [] if
|
|
the requirement is not applicable, or [install_req] if the
|
|
requirement is applicable and has just been added.
|
|
"""
|
|
# If the markers do not match, ignore this requirement.
|
|
if not install_req.match_markers(extras_requested):
|
|
logger.info(
|
|
"Ignoring %s: markers '%s' don't match your environment",
|
|
install_req.name,
|
|
install_req.markers,
|
|
)
|
|
return [], None
|
|
|
|
# If the wheel is not supported, raise an error.
|
|
# Should check this after filtering out based on environment markers to
|
|
# allow specifying different wheels based on the environment/OS, in a
|
|
# single requirements file.
|
|
if install_req.link and install_req.link.is_wheel:
|
|
wheel = Wheel(install_req.link.filename)
|
|
tags = compatibility_tags.get_supported()
|
|
if requirement_set.check_supported_wheels and not wheel.supported(tags):
|
|
raise InstallationError(
|
|
"{} is not a supported wheel on this platform.".format(
|
|
wheel.filename
|
|
)
|
|
)
|
|
|
|
# This next bit is really a sanity check.
|
|
assert (
|
|
not install_req.user_supplied or parent_req_name is None
|
|
), "a user supplied req shouldn't have a parent"
|
|
|
|
# Unnamed requirements are scanned again and the requirement won't be
|
|
# added as a dependency until after scanning.
|
|
if not install_req.name:
|
|
requirement_set.add_unnamed_requirement(install_req)
|
|
return [install_req], None
|
|
|
|
try:
|
|
existing_req: Optional[
|
|
InstallRequirement
|
|
] = requirement_set.get_requirement(install_req.name)
|
|
except KeyError:
|
|
existing_req = None
|
|
|
|
has_conflicting_requirement = (
|
|
parent_req_name is None
|
|
and existing_req
|
|
and not existing_req.constraint
|
|
and existing_req.extras == install_req.extras
|
|
and existing_req.req
|
|
and install_req.req
|
|
and existing_req.req.specifier != install_req.req.specifier
|
|
)
|
|
if has_conflicting_requirement:
|
|
raise InstallationError(
|
|
"Double requirement given: {} (already in {}, name={!r})".format(
|
|
install_req, existing_req, install_req.name
|
|
)
|
|
)
|
|
|
|
# When no existing requirement exists, add the requirement as a
|
|
# dependency and it will be scanned again after.
|
|
if not existing_req:
|
|
requirement_set.add_named_requirement(install_req)
|
|
# We'd want to rescan this requirement later
|
|
return [install_req], install_req
|
|
|
|
# Assume there's no need to scan, and that we've already
|
|
# encountered this for scanning.
|
|
if install_req.constraint or not existing_req.constraint:
|
|
return [], existing_req
|
|
|
|
does_not_satisfy_constraint = install_req.link and not (
|
|
existing_req.link and install_req.link.path == existing_req.link.path
|
|
)
|
|
if does_not_satisfy_constraint:
|
|
raise InstallationError(
|
|
"Could not satisfy constraints for '{}': "
|
|
"installation from path or url cannot be "
|
|
"constrained to a version".format(install_req.name)
|
|
)
|
|
# If we're now installing a constraint, mark the existing
|
|
# object for real installation.
|
|
existing_req.constraint = False
|
|
# If we're now installing a user supplied requirement,
|
|
# mark the existing object as such.
|
|
if install_req.user_supplied:
|
|
existing_req.user_supplied = True
|
|
existing_req.extras = tuple(
|
|
sorted(set(existing_req.extras) | set(install_req.extras))
|
|
)
|
|
logger.debug(
|
|
"Setting %s extras to: %s",
|
|
existing_req,
|
|
existing_req.extras,
|
|
)
|
|
# Return the existing requirement for addition to the parent and
|
|
# scanning again.
|
|
return [existing_req], existing_req
|
|
|
|
def _is_upgrade_allowed(self, req: InstallRequirement) -> bool:
|
|
if self.upgrade_strategy == "to-satisfy-only":
|
|
return False
|
|
elif self.upgrade_strategy == "eager":
|
|
return True
|
|
else:
|
|
assert self.upgrade_strategy == "only-if-needed"
|
|
return req.user_supplied or req.constraint
|
|
|
|
def _set_req_to_reinstall(self, req: InstallRequirement) -> None:
|
|
"""
|
|
Set a requirement to be installed.
|
|
"""
|
|
# Don't uninstall the conflict if doing a user install and the
|
|
# conflict is not a user install.
|
|
if not self.use_user_site or req.satisfied_by.in_usersite:
|
|
req.should_reinstall = True
|
|
req.satisfied_by = None
|
|
|
|
def _check_skip_installed(
|
|
self, req_to_install: InstallRequirement
|
|
) -> Optional[str]:
|
|
"""Check if req_to_install should be skipped.
|
|
|
|
This will check if the req is installed, and whether we should upgrade
|
|
or reinstall it, taking into account all the relevant user options.
|
|
|
|
After calling this req_to_install will only have satisfied_by set to
|
|
None if the req_to_install is to be upgraded/reinstalled etc. Any
|
|
other value will be a dist recording the current thing installed that
|
|
satisfies the requirement.
|
|
|
|
Note that for vcs urls and the like we can't assess skipping in this
|
|
routine - we simply identify that we need to pull the thing down,
|
|
then later on it is pulled down and introspected to assess upgrade/
|
|
reinstalls etc.
|
|
|
|
:return: A text reason for why it was skipped, or None.
|
|
"""
|
|
if self.ignore_installed:
|
|
return None
|
|
|
|
req_to_install.check_if_exists(self.use_user_site)
|
|
if not req_to_install.satisfied_by:
|
|
return None
|
|
|
|
if self.force_reinstall:
|
|
self._set_req_to_reinstall(req_to_install)
|
|
return None
|
|
|
|
if not self._is_upgrade_allowed(req_to_install):
|
|
if self.upgrade_strategy == "only-if-needed":
|
|
return "already satisfied, skipping upgrade"
|
|
return "already satisfied"
|
|
|
|
# Check for the possibility of an upgrade. For link-based
|
|
# requirements we have to pull the tree down and inspect to assess
|
|
# the version #, so it's handled way down.
|
|
if not req_to_install.link:
|
|
try:
|
|
self.finder.find_requirement(req_to_install, upgrade=True)
|
|
except BestVersionAlreadyInstalled:
|
|
# Then the best version is installed.
|
|
return "already up-to-date"
|
|
except DistributionNotFound:
|
|
# No distribution found, so we squash the error. It will
|
|
# be raised later when we re-try later to do the install.
|
|
# Why don't we just raise here?
|
|
pass
|
|
|
|
self._set_req_to_reinstall(req_to_install)
|
|
return None
|
|
|
|
def _find_requirement_link(self, req: InstallRequirement) -> Optional[Link]:
|
|
upgrade = self._is_upgrade_allowed(req)
|
|
best_candidate = self.finder.find_requirement(req, upgrade)
|
|
if not best_candidate:
|
|
return None
|
|
|
|
# Log a warning per PEP 592 if necessary before returning.
|
|
link = best_candidate.link
|
|
if link.is_yanked:
|
|
reason = link.yanked_reason or "<none given>"
|
|
msg = (
|
|
# Mark this as a unicode string to prevent
|
|
# "UnicodeEncodeError: 'ascii' codec can't encode character"
|
|
# in Python 2 when the reason contains non-ascii characters.
|
|
"The candidate selected for download or install is a "
|
|
"yanked version: {candidate}\n"
|
|
"Reason for being yanked: {reason}"
|
|
).format(candidate=best_candidate, reason=reason)
|
|
logger.warning(msg)
|
|
|
|
return link
|
|
|
|
def _populate_link(self, req: InstallRequirement) -> None:
|
|
"""Ensure that if a link can be found for this, that it is found.
|
|
|
|
Note that req.link may still be None - if the requirement is already
|
|
installed and not needed to be upgraded based on the return value of
|
|
_is_upgrade_allowed().
|
|
|
|
If preparer.require_hashes is True, don't use the wheel cache, because
|
|
cached wheels, always built locally, have different hashes than the
|
|
files downloaded from the index server and thus throw false hash
|
|
mismatches. Furthermore, cached wheels at present have undeterministic
|
|
contents due to file modification times.
|
|
"""
|
|
if req.link is None:
|
|
req.link = self._find_requirement_link(req)
|
|
|
|
if self.wheel_cache is None or self.preparer.require_hashes:
|
|
return
|
|
cache_entry = self.wheel_cache.get_cache_entry(
|
|
link=req.link,
|
|
package_name=req.name,
|
|
supported_tags=get_supported(),
|
|
)
|
|
if cache_entry is not None:
|
|
logger.debug("Using cached wheel link: %s", cache_entry.link)
|
|
if req.link is req.original_link and cache_entry.persistent:
|
|
req.cached_wheel_source_link = req.link
|
|
if cache_entry.origin is not None:
|
|
req.download_info = cache_entry.origin
|
|
else:
|
|
# Legacy cache entry that does not have origin.json.
|
|
# download_info may miss the archive_info.hashes field.
|
|
req.download_info = direct_url_from_link(
|
|
req.link, link_is_in_wheel_cache=cache_entry.persistent
|
|
)
|
|
req.link = cache_entry.link
|
|
|
|
def _get_dist_for(self, req: InstallRequirement) -> BaseDistribution:
|
|
"""Takes a InstallRequirement and returns a single AbstractDist \
|
|
representing a prepared variant of the same.
|
|
"""
|
|
if req.editable:
|
|
return self.preparer.prepare_editable_requirement(req)
|
|
|
|
# satisfied_by is only evaluated by calling _check_skip_installed,
|
|
# so it must be None here.
|
|
assert req.satisfied_by is None
|
|
skip_reason = self._check_skip_installed(req)
|
|
|
|
if req.satisfied_by:
|
|
return self.preparer.prepare_installed_requirement(req, skip_reason)
|
|
|
|
# We eagerly populate the link, since that's our "legacy" behavior.
|
|
self._populate_link(req)
|
|
dist = self.preparer.prepare_linked_requirement(req)
|
|
|
|
# NOTE
|
|
# The following portion is for determining if a certain package is
|
|
# going to be re-installed/upgraded or not and reporting to the user.
|
|
# This should probably get cleaned up in a future refactor.
|
|
|
|
# req.req is only avail after unpack for URL
|
|
# pkgs repeat check_if_exists to uninstall-on-upgrade
|
|
# (#14)
|
|
if not self.ignore_installed:
|
|
req.check_if_exists(self.use_user_site)
|
|
|
|
if req.satisfied_by:
|
|
should_modify = (
|
|
self.upgrade_strategy != "to-satisfy-only"
|
|
or self.force_reinstall
|
|
or self.ignore_installed
|
|
or req.link.scheme == "file"
|
|
)
|
|
if should_modify:
|
|
self._set_req_to_reinstall(req)
|
|
else:
|
|
logger.info(
|
|
"Requirement already satisfied (use --upgrade to upgrade): %s",
|
|
req,
|
|
)
|
|
return dist
|
|
|
|
def _resolve_one(
|
|
self,
|
|
requirement_set: RequirementSet,
|
|
req_to_install: InstallRequirement,
|
|
) -> List[InstallRequirement]:
|
|
"""Prepare a single requirements file.
|
|
|
|
:return: A list of additional InstallRequirements to also install.
|
|
"""
|
|
# Tell user what we are doing for this requirement:
|
|
# obtain (editable), skipping, processing (local url), collecting
|
|
# (remote url or package name)
|
|
if req_to_install.constraint or req_to_install.prepared:
|
|
return []
|
|
|
|
req_to_install.prepared = True
|
|
|
|
# Parse and return dependencies
|
|
dist = self._get_dist_for(req_to_install)
|
|
# This will raise UnsupportedPythonVersion if the given Python
|
|
# version isn't compatible with the distribution's Requires-Python.
|
|
_check_dist_requires_python(
|
|
dist,
|
|
version_info=self._py_version_info,
|
|
ignore_requires_python=self.ignore_requires_python,
|
|
)
|
|
|
|
more_reqs: List[InstallRequirement] = []
|
|
|
|
def add_req(subreq: Requirement, extras_requested: Iterable[str]) -> None:
|
|
# This idiosyncratically converts the Requirement to str and let
|
|
# make_install_req then parse it again into Requirement. But this is
|
|
# the legacy resolver so I'm just not going to bother refactoring.
|
|
sub_install_req = self._make_install_req(str(subreq), req_to_install)
|
|
parent_req_name = req_to_install.name
|
|
to_scan_again, add_to_parent = self._add_requirement_to_set(
|
|
requirement_set,
|
|
sub_install_req,
|
|
parent_req_name=parent_req_name,
|
|
extras_requested=extras_requested,
|
|
)
|
|
if parent_req_name and add_to_parent:
|
|
self._discovered_dependencies[parent_req_name].append(add_to_parent)
|
|
more_reqs.extend(to_scan_again)
|
|
|
|
with indent_log():
|
|
# We add req_to_install before its dependencies, so that we
|
|
# can refer to it when adding dependencies.
|
|
if not requirement_set.has_requirement(req_to_install.name):
|
|
# 'unnamed' requirements will get added here
|
|
# 'unnamed' requirements can only come from being directly
|
|
# provided by the user.
|
|
assert req_to_install.user_supplied
|
|
self._add_requirement_to_set(
|
|
requirement_set, req_to_install, parent_req_name=None
|
|
)
|
|
|
|
if not self.ignore_dependencies:
|
|
if req_to_install.extras:
|
|
logger.debug(
|
|
"Installing extra requirements: %r",
|
|
",".join(req_to_install.extras),
|
|
)
|
|
missing_requested = sorted(
|
|
set(req_to_install.extras) - set(dist.iter_provided_extras())
|
|
)
|
|
for missing in missing_requested:
|
|
logger.warning(
|
|
"%s %s does not provide the extra '%s'",
|
|
dist.raw_name,
|
|
dist.version,
|
|
missing,
|
|
)
|
|
|
|
available_requested = sorted(
|
|
set(dist.iter_provided_extras()) & set(req_to_install.extras)
|
|
)
|
|
for subreq in dist.iter_dependencies(available_requested):
|
|
add_req(subreq, extras_requested=available_requested)
|
|
|
|
return more_reqs
|
|
|
|
def get_installation_order(
|
|
self, req_set: RequirementSet
|
|
) -> List[InstallRequirement]:
|
|
"""Create the installation order.
|
|
|
|
The installation order is topological - requirements are installed
|
|
before the requiring thing. We break cycles at an arbitrary point,
|
|
and make no other guarantees.
|
|
"""
|
|
# The current implementation, which we may change at any point
|
|
# installs the user specified things in the order given, except when
|
|
# dependencies must come earlier to achieve topological order.
|
|
order = []
|
|
ordered_reqs: Set[InstallRequirement] = set()
|
|
|
|
def schedule(req: InstallRequirement) -> None:
|
|
if req.satisfied_by or req in ordered_reqs:
|
|
return
|
|
if req.constraint:
|
|
return
|
|
ordered_reqs.add(req)
|
|
for dep in self._discovered_dependencies[req.name]:
|
|
schedule(dep)
|
|
order.append(req)
|
|
|
|
for install_req in req_set.requirements.values():
|
|
schedule(install_req)
|
|
return order
|