about summary refs log tree commit diff
path: root/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py')
-rw-r--r--pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py97
1 files changed, 97 insertions, 0 deletions
diff --git a/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py b/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py
new file mode 100644
index 0000000000000..5a3a919391753
--- /dev/null
+++ b/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""
+The runtimeDependenciesHook validates, that all dependencies specified
+in wheel metadata are available in the local environment.
+
+In case that does not hold, it will print missing dependencies and
+violated version constraints.
+"""
+
+
+import importlib.metadata
+import re
+import sys
+import tempfile
+from argparse import ArgumentParser
+from zipfile import ZipFile
+
+from packaging.metadata import Metadata, parse_email
+from packaging.requirements import Requirement
+
+argparser = ArgumentParser()
+argparser.add_argument("wheel", help="Path to the .whl file to test")
+
+
+def error(msg: str) -> None:
+    print(f"  - {msg}", file=sys.stderr)
+
+
+def normalize_name(name: str) -> str:
+    """
+    Normalize package names according to PEP503
+    """
+    return re.sub(r"[-_.]+", "-", name).lower()
+
+
+def get_manifest_text_from_wheel(wheel: str) -> str:
+    """
+    Given a path to a wheel, this function will try to extract the
+    METADATA file in the wheels .dist-info directory.
+    """
+    with ZipFile(wheel) as zipfile:
+        for zipinfo in zipfile.infolist():
+            if zipinfo.filename.endswith(".dist-info/METADATA"):
+                with tempfile.TemporaryDirectory() as tmp:
+                    path = zipfile.extract(zipinfo, path=tmp)
+                    with open(path, encoding="utf-8") as fd:
+                        return fd.read()
+
+    raise RuntimeError("No METADATA file found in wheel")
+
+
+def get_metadata(wheel: str) -> Metadata:
+    """
+    Given a path to a wheel, returns a parsed Metadata object.
+    """
+    text = get_manifest_text_from_wheel(wheel)
+    raw, _ = parse_email(text)
+    metadata = Metadata.from_raw(raw)
+
+    return metadata
+
+
+def test_requirement(requirement: Requirement) -> bool:
+    """
+    Given a requirement specification, tests whether the dependency can
+    be resolved in the local environment, and whether it satisfies the
+    specified version constraints.
+    """
+    if requirement.marker and not requirement.marker.evaluate():
+        # ignore requirements with incompatible markers
+        return True
+
+    package_name = normalize_name(requirement.name)
+
+    try:
+        package = importlib.metadata.distribution(requirement.name)
+    except importlib.metadata.PackageNotFoundError:
+        error(f"{package_name} not installed")
+        return False
+
+    if package.version not in requirement.specifier:
+        error(
+            f"{package_name}{requirement.specifier} not satisfied by version {package.version}"
+        )
+        return False
+
+    return True
+
+
+if __name__ == "__main__":
+    args = argparser.parse_args()
+
+    metadata = get_metadata(args.wheel)
+    tests = [test_requirement(requirement) for requirement in metadata.requires_dist]
+
+    if not all(tests):
+        sys.exit(1)