1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
|
"""
Check packages for version and architecture conflicts.
"""
# stdlib
import sys
from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union
# 3rd party
import click
from domdf_python_tools.words import Plural, PluralPhrase, word_join
from natsort import natsorted
from packaging.requirements import Requirement
from packaging.tags import parse_tag, platform_tags
from packaging.utils import NormalizedName, canonicalize_name
from packaging.version import LegacyVersion, Version
# this package
from dist_meta.distributions import Distribution, iter_distributions
supported_tags = set(platform_tags())
supported_tags.add("any")
_supported_platforms = PluralPhrase(
"The {} it supports {}",
(Plural("platform", "platforms"), Plural("is", "are")),
)
def print_tags(ctx, param, tags: bool = False):
if not tags or ctx.resilient_parsing:
return
print('\n'.join(natsorted(supported_tags)))
ctx.exit()
DistributionVersion = Union[LegacyVersion, Version]
class PackageDetails(NamedTuple):
version: DistributionVersion
dependencies: List[Requirement]
wheel_platform_tags: Set[str]
# Shorthands
PackageSet = Dict[NormalizedName, PackageDetails]
Missing = Tuple[NormalizedName, Requirement]
Conflicting = Tuple[NormalizedName, DistributionVersion, Requirement]
MissingDict = Dict[NormalizedName, List[Missing]]
ConflictingDict = Dict[NormalizedName, List[Conflicting]]
ArchMismatchDict = Dict[NormalizedName, Set[str]]
CheckResult = Tuple[MissingDict, ConflictingDict, ArchMismatchDict]
def get_wheel_platform_tags(dist: Distribution) -> Set[str]:
"""
Returns a list of wheel platform tags (i.e. the supported platforms) for the given distribution.
:param dist:
:return:
"""
wheel_platform_tags = set()
wheel_file_content = dist.get_wheel()
if wheel_file_content:
# Might not have a WHEEL file; might not be installed from a wheel.
for w_tag in wheel_file_content.get_all("Tag", ()):
for tag in parse_tag(w_tag):
wheel_platform_tags.add(tag.platform)
return wheel_platform_tags
def create_package_set_from_installed(path) -> Tuple[PackageSet, bool]:
"""
Converts a list of distributions into a PackageSet.
:param path: A list of Python directories to find dependencies in. Akin to :py:obj:`sys.path`.
"""
package_set: PackageSet = {}
parsing_probs = False
for dist in iter_distributions(path):
name = canonicalize_name(dist.name)
try:
# TODO: extras?
raw_dependencies = dist.get_metadata().get_all("Requires-Dist", default=())
package_set[name] = PackageDetails(
version=dist.version,
dependencies=list(map(Requirement, raw_dependencies)),
wheel_platform_tags=get_wheel_platform_tags(dist),
)
except (OSError, ValueError) as e:
# Don't crash on unreadable or broken metadata.
print(f"Error parsing requirements for {name}: {e}")
parsing_probs = True
return package_set, parsing_probs
def get_conflicts(package_set: PackageSet) -> CheckResult:
"""
Identify conflicting dependencies and architectures, and missing packages, in the package set.
:param package_set:
"""
missing: MissingDict = {}
conflicting: ConflictingDict = {}
arch_mismatch: ArchMismatchDict = {}
for package_name, package_detail in package_set.items():
# Info about dependencies of package_name
missing_deps: Set[Missing] = set()
conflicting_deps: Set[Conflicting] = set()
for req in package_detail.dependencies:
name = canonicalize_name(req.name)
# Check if it's missing
if name not in package_set:
missed = True
if req.marker is not None:
missed = req.marker.evaluate({"extra": None})
if missed:
missing_deps.add((name, req))
continue
elif req.marker is not None and not req.marker.evaluate({"extra": None}):
continue
# Check if there's a conflict
version = package_set[name].version
if not req.specifier.contains(version, prereleases=True):
conflicting_deps.add((name, version, req))
if missing_deps:
missing[package_name] = sorted(missing_deps, key=str)
if conflicting_deps:
conflicting[package_name] = sorted(conflicting_deps, key=str)
tag_intersection = supported_tags.intersection(package_detail.wheel_platform_tags)
if not tag_intersection:
arch_mismatch[package_name] = package_detail.wheel_platform_tags
return missing, conflicting, arch_mismatch
def check(path: Optional[Tuple[str, ...]] = None) -> int:
"""
Check packages for version and architecture conflicts.
:param path: A list of Python directories to find dependencies in. Akin to :py:obj:`sys.path`.
:return: ``0`` if there are no conflicts, ``1`` if there are conflicts or parsing errors.
"""
package_set, parsing_probs = create_package_set_from_installed(path)
missing, conflicting, arch_mismatch = get_conflicts(package_set)
for project_name in missing:
version = package_set[project_name].version
for dependency in missing[project_name]:
print(f"{project_name} {version} requires {dependency[0]}, which is not installed.")
for project_name in conflicting:
version = package_set[project_name].version
for dep_name, dep_version, req in conflicting[project_name]:
print(f"{project_name} {version} has requirement {req}, but you have {dep_name} {dep_version}.")
for project_name in arch_mismatch:
version = package_set[project_name].version
wheel_platform_tags = arch_mismatch[project_name]
num_tags = len(wheel_platform_tags)
print(
f"{project_name} {version} is not supported by this platform.\n",
f" {_supported_platforms(num_tags)} {word_join(sorted(wheel_platform_tags), use_repr=True)}."
)
if any((missing, conflicting, arch_mismatch, parsing_probs)):
return 1
else:
print("No broken requirements found.")
return 0
@click.option("-p", "--path", required=False, multiple=True)
@click.option(
"-t",
"--tags",
help="Print the supported platform tags, one per line, and exit.",
callback=print_tags,
expose_value=False,
is_eager=True,
is_flag=True,
)
@click.command()
def main(path: Tuple[str, ...]):
if not path:
path = None
sys.exit(check(path))
if __name__ == "__main__":
main()
|