1#!/usr/bin/env python3
2"""
3The runtimeDependenciesHook validates, that all dependencies specified
4in wheel metadata are available in the local environment.
5
6In case that does not hold, it will print missing dependencies and
7violated version constraints.
8"""
9
10
11import importlib.metadata
12import re
13import sys
14import tempfile
15from argparse import ArgumentParser
16from zipfile import ZipFile
17
18from packaging.metadata import Metadata, parse_email
19from packaging.requirements import Requirement
20
21argparser = ArgumentParser()
22argparser.add_argument("wheel", help="Path to the .whl file to test")
23
24
25def error(msg: str) -> None:
26 print(f" - {msg}", file=sys.stderr)
27
28
29def normalize_name(name: str) -> str:
30 """
31 Normalize package names according to PEP503
32 """
33 return re.sub(r"[-_.]+", "-", name).lower()
34
35
36def get_manifest_text_from_wheel(wheel: str) -> str:
37 """
38 Given a path to a wheel, this function will try to extract the
39 METADATA file in the wheels .dist-info directory.
40 """
41 with ZipFile(wheel) as zipfile:
42 for zipinfo in zipfile.infolist():
43 if zipinfo.filename.endswith(".dist-info/METADATA"):
44 with tempfile.TemporaryDirectory() as tmp:
45 path = zipfile.extract(zipinfo, path=tmp)
46 with open(path, encoding="utf-8") as fd:
47 return fd.read()
48
49 raise RuntimeError("No METADATA file found in wheel")
50
51
52def get_metadata(wheel: str) -> Metadata:
53 """
54 Given a path to a wheel, returns a parsed Metadata object.
55 """
56 text = get_manifest_text_from_wheel(wheel)
57 raw, _ = parse_email(text)
58 metadata = Metadata.from_raw(raw, validate=False)
59
60 return metadata
61
62
63def test_requirement(requirement: Requirement) -> bool:
64 """
65 Given a requirement specification, tests whether the dependency can
66 be resolved in the local environment, and whether it satisfies the
67 specified version constraints.
68 """
69 if requirement.marker and not requirement.marker.evaluate():
70 # ignore requirements with incompatible markers
71 return True
72
73 package_name = normalize_name(requirement.name)
74
75 try:
76 package = importlib.metadata.distribution(requirement.name)
77 except importlib.metadata.PackageNotFoundError:
78 error(f"{package_name} not installed")
79 return False
80
81 # Allow prereleases, to give to give us some wiggle-room
82 requirement.specifier.prereleases = True
83
84 if requirement.specifier and package.version not in requirement.specifier:
85 error(
86 f"{package_name}{requirement.specifier} not satisfied by version {package.version}"
87 )
88 return False
89
90 return True
91
92
93if __name__ == "__main__":
94 args = argparser.parse_args()
95
96 metadata = get_metadata(args.wheel)
97 requirements = metadata.requires_dist
98
99 if not requirements:
100 sys.exit(0)
101
102 tests = [test_requirement(requirement) for requirement in requirements]
103
104 if not all(tests):
105 sys.exit(1)