python3.pkgs.pythonRuntimeDepsCheckHook: init

Implements a hook, that checks whether all dependencies, as specified by
the wheel manifest, are present in the current environment.

Complains about missing packages, as well as version specifier
mismatches.
This commit is contained in:
Martin Weinelt 2023-11-27 19:14:35 +01:00
parent a648bdeede
commit 8f3162f83f
No known key found for this signature in database
GPG key ID: 87C1E9888F856759
6 changed files with 168 additions and 0 deletions

View file

@ -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";

View file

@ -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)

View file

@ -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

View file

@ -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") [

View file

@ -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
'';
}

View file

@ -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 { };