diff options
Diffstat (limited to 'nixpkgs/pkgs/development/interpreters/python/hooks')
4 files changed, 128 insertions, 1 deletions
diff --git a/nixpkgs/pkgs/development/interpreters/python/hooks/default.nix b/nixpkgs/pkgs/development/interpreters/python/hooks/default.nix index d06f3db334da..0557c62eeff4 100644 --- a/nixpkgs/pkgs/development/interpreters/python/hooks/default.nix +++ b/nixpkgs/pkgs/development/interpreters/python/hooks/default.nix @@ -173,6 +173,16 @@ in { }; } ./python-remove-tests-dir-hook.sh) {}; + pythonRuntimeDepsCheckHook = callPackage ({ makePythonHook, packaging }: + makePythonHook { + name = "python-runtime-deps-check-hook.sh"; + propagatedBuildInputs = [ packaging ]; + substitutions = { + inherit pythonInterpreter pythonSitePackages; + hook = ./python-runtime-deps-check-hook.py; + }; + } ./python-runtime-deps-check-hook.sh) {}; + setuptoolsBuildHook = callPackage ({ makePythonHook, setuptools, wheel }: makePythonHook { name = "setuptools-setup-hook"; diff --git a/nixpkgs/pkgs/development/interpreters/python/hooks/python-relax-deps-hook.sh b/nixpkgs/pkgs/development/interpreters/python/hooks/python-relax-deps-hook.sh index 1ac91fb40e4e..293bd5cebd50 100644 --- a/nixpkgs/pkgs/development/interpreters/python/hooks/python-relax-deps-hook.sh +++ b/nixpkgs/pkgs/development/interpreters/python/hooks/python-relax-deps-hook.sh @@ -52,7 +52,7 @@ _pythonRelaxDeps() { else for dep in $pythonRelaxDeps; do sed -i "$metadata_file" -r \ - -e "s/(Requires-Dist: $dep\s*(\[[^]]+\])?)[^;]*(;.*)?/\1\3/" + -e "s/(Requires-Dist: $dep\s*(\[[^]]+\])?)[^;]*(;.*)?/\1\3/i" done fi } diff --git a/nixpkgs/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py b/nixpkgs/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.py new file mode 100644 index 000000000000..5a3a91939175 --- /dev/null +++ b/nixpkgs/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) diff --git a/nixpkgs/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.sh b/nixpkgs/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.sh new file mode 100644 index 000000000000..43a2f9b88745 --- /dev/null +++ b/nixpkgs/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.sh @@ -0,0 +1,20 @@ +# Setup hook for PyPA installer. +echo "Sourcing python-runtime-deps-check-hook" + +pythonRuntimeDepsCheckHook() { + echo "Executing pythonRuntimeDepsCheck" + + export PYTHONPATH="$out/@pythonSitePackages@:$PYTHONPATH" + + for wheel in dist/*.whl; do + echo "Checking runtime dependencies for $(basename $wheel)" + @pythonInterpreter@ @hook@ "$wheel" + done + + echo "Finished executing pythonRuntimeDepsCheck" +} + +if [ -z "${dontCheckRuntimeDeps-}" ]; then + echo "Using pythonRuntimeDepsCheckHook" + preInstallPhases+=" pythonRuntimeDepsCheckHook" +fi |