diff --git a/pkgs/development/interpreters/python/hooks/default.nix b/pkgs/development/interpreters/python/hooks/default.nix index 35116b38f810..83c6be2f5382 100644 --- a/pkgs/development/interpreters/python/hooks/default.nix +++ b/pkgs/development/interpreters/python/hooks/default.nix @@ -172,6 +172,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/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 000000000000..5a3a91939175 --- /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) diff --git a/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.sh b/pkgs/development/interpreters/python/hooks/python-runtime-deps-check-hook.sh new file mode 100644 index 000000000000..43a2f9b88745 --- /dev/null +++ b/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 diff --git a/pkgs/development/interpreters/python/mk-python-derivation.nix b/pkgs/development/interpreters/python/mk-python-derivation.nix index c14c6bc096fd..e6f9087de866 100644 --- a/pkgs/development/interpreters/python/mk-python-derivation.nix +++ b/pkgs/development/interpreters/python/mk-python-derivation.nix @@ -19,6 +19,7 @@ , pythonOutputDistHook , pythonRemoveBinBytecodeHook , pythonRemoveTestsDirHook +, pythonRuntimeDepsCheckHook , setuptoolsBuildHook , setuptoolsCheckHook , wheelUnpackHook @@ -229,6 +230,13 @@ let } else pypaBuildHook + ) ( + if isBootstrapPackage then + pythonRuntimeDepsCheckHook.override { + inherit (python.pythonOnBuildForHost.pkgs.bootstrap) packaging; + } + else + pythonRuntimeDepsCheckHook )] ++ lib.optionals (format' == "wheel") [ wheelUnpackHook ] ++ lib.optionals (format' == "egg") [ diff --git a/pkgs/development/python-modules/bootstrap/packaging/default.nix b/pkgs/development/python-modules/bootstrap/packaging/default.nix new file mode 100644 index 000000000000..f8a10d4ddd12 --- /dev/null +++ b/pkgs/development/python-modules/bootstrap/packaging/default.nix @@ -0,0 +1,30 @@ +{ stdenv +, python +, flit-core +, installer +, packaging +}: + +stdenv.mkDerivation { + pname = "${python.libPrefix}-bootstrap-${packaging.pname}"; + inherit (packaging) version src meta; + + buildPhase = '' + runHook preBuild + + PYTHONPATH="${flit-core}/${python.sitePackages}" \ + ${python.interpreter} -m flit_core.wheel + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + PYTHONPATH="${installer}/${python.sitePackages}" \ + ${python.interpreter} -m installer \ + --destdir "$out" --prefix "" dist/*.whl + + runHook postInstall + ''; +} diff --git a/pkgs/top-level/python-packages.nix b/pkgs/top-level/python-packages.nix index 29bad9f62b31..56389f32c35d 100644 --- a/pkgs/top-level/python-packages.nix +++ b/pkgs/top-level/python-packages.nix @@ -16,6 +16,9 @@ self: super: with self; { build = toPythonModule (callPackage ../development/python-modules/bootstrap/build { inherit (bootstrap) flit-core installer; }); + packaging = toPythonModule (callPackage ../development/python-modules/bootstrap/packaging { + inherit (bootstrap) flit-core installer; + }); }; setuptools = callPackage ../development/python-modules/setuptools { };