Merge pull request #287957 from DavHau/python

pythonCatchConflictsHook: scan $out, not sys.path (2)

authored by lassulus and committed by GitHub 38905fc7 4b315ab2

+204 -14
+1 -1
doc/languages-frameworks/python.section.md
··· 469 469 be added as `nativeBuildInput`. 470 470 - `pipInstallHook` to install wheels. 471 471 - `pytestCheckHook` to run tests with `pytest`. See [example usage](#using-pytestcheckhook). 472 - - `pythonCatchConflictsHook` to check whether a Python package is not already existing. 472 + - `pythonCatchConflictsHook` to fail if the package depends on two different versions of the same dependency. 473 473 - `pythonImportsCheckHook` to check whether importing the listed modules works. 474 474 - `pythonRelaxDepsHook` will relax Python dependencies restrictions for the package. 475 475 See [example usage](#using-pythonrelaxdepshook).
+61 -12
pkgs/development/interpreters/python/catch_conflicts/catch_conflicts.py
··· 2 2 from pathlib import Path 3 3 import collections 4 4 import sys 5 + import os 6 + from typing import Dict, List, Tuple 7 + do_abort: bool = False 8 + packages: Dict[str, Dict[str, List[Dict[str, List[str]]]]] = collections.defaultdict(list) 9 + out_path: Path = Path(os.getenv("out")) 10 + version: Tuple[int, int] = sys.version_info 11 + site_packages_path: str = f'lib/python{version[0]}.{version[1]}/site-packages' 12 + 13 + 14 + def get_name(dist: PathDistribution) -> str: 15 + return dist.metadata['name'].lower().replace('-', '_') 16 + 17 + 18 + # pretty print a package 19 + def describe_package(dist: PathDistribution) -> str: 20 + return f"{get_name(dist)} {dist.version} ({dist._path})" 21 + 22 + 23 + # pretty print a list of parents (dependency chain) 24 + def describe_parents(parents: List[str]) -> str: 25 + if not parents: 26 + return "" 27 + return \ 28 + f" dependency chain:\n " \ 29 + + str(f"\n ...depending on: ".join(parents)) 30 + 31 + 32 + # inserts an entry into 'packages' 33 + def add_entry(name: str, version: str, store_path: str, parents: List[str]) -> None: 34 + if name not in packages: 35 + packages[name] = {} 36 + if store_path not in packages[name]: 37 + packages[name][store_path] = [] 38 + packages[name][store_path].append(dict( 39 + version=version, 40 + parents=parents, 41 + )) 5 42 6 43 7 - do_abort = False 8 - packages = collections.defaultdict(list) 44 + # transitively discover python dependencies and store them in 'packages' 45 + def find_packages(store_path: Path, site_packages_path: str, parents: List[str]) -> None: 46 + site_packages: Path = (store_path / site_packages_path) 47 + propagated_build_inputs: Path = (store_path / "nix-support/propagated-build-inputs") 9 48 49 + # add the current package to the list 50 + if site_packages.exists(): 51 + for dist_info in site_packages.glob("*.dist-info"): 52 + dist: PathDistribution = PathDistribution(dist_info) 53 + add_entry(get_name(dist), dist.version, store_path, parents) 10 54 11 - for path in sys.path: 12 - for dist_info in Path(path).glob("*.dist-info"): 13 - dist = PathDistribution(dist_info) 55 + # recursively add dependencies 56 + if propagated_build_inputs.exists(): 57 + with open(propagated_build_inputs, "r") as f: 58 + build_inputs: List[str] = f.read().strip().split(" ") 59 + for build_input in build_inputs: 60 + find_packages(Path(build_input), site_packages_path, parents + [build_input]) 14 61 15 - packages[dist._normalized_name].append( 16 - f"{dist._normalized_name} {dist.version} ({dist._path})" 17 - ) 18 62 63 + find_packages(out_path, site_packages_path, [f"this derivation: {out_path}"]) 19 64 20 - for name, duplicates in packages.items(): 21 - if len(duplicates) > 1: 65 + # print all duplicates 66 + for name, store_paths in packages.items(): 67 + if len(store_paths) > 1: 22 68 do_abort = True 23 69 print("Found duplicated packages in closure for dependency '{}': ".format(name)) 24 - for duplicate in duplicates: 25 - print(f"\t{duplicate}") 70 + for store_path, candidates in store_paths.items(): 71 + for candidate in candidates: 72 + print(f" {name} {candidate['version']} ({store_path})") 73 + print(describe_parents(candidate['parents'])) 26 74 75 + # fail if duplicates were found 27 76 if do_abort: 28 77 print("") 29 78 print(
+5 -1
pkgs/development/interpreters/python/hooks/default.nix
··· 108 108 makePythonHook { 109 109 name = "python-catch-conflicts-hook"; 110 110 substitutions = let 111 - useLegacyHook = lib.versionOlder python.pythonVersion "3.10"; 111 + useLegacyHook = lib.versionOlder python.pythonVersion "3"; 112 112 in { 113 113 inherit pythonInterpreter pythonSitePackages; 114 114 catchConflicts = if useLegacyHook then ··· 117 117 ../catch_conflicts/catch_conflicts.py; 118 118 } // lib.optionalAttrs useLegacyHook { 119 119 inherit setuptools; 120 + }; 121 + passthru.tests = import ./python-catch-conflicts-hook-tests.nix { 122 + inherit pythonOnBuildForHost runCommand; 123 + inherit (pkgs) coreutils gnugrep writeShellScript; 120 124 }; 121 125 } ./python-catch-conflicts-hook.sh) {}; 122 126
+137
pkgs/development/interpreters/python/hooks/python-catch-conflicts-hook-tests.nix
··· 1 + { pythonOnBuildForHost, runCommand, writeShellScript, coreutils, gnugrep }: let 2 + 3 + pythonPkgs = pythonOnBuildForHost.pkgs; 4 + 5 + ### UTILITIES 6 + 7 + # customize a package so that its store paths differs 8 + customize = pkg: pkg.overrideAttrs { some_modification = true; }; 9 + 10 + # generates minimal pyproject.toml 11 + pyprojectToml = pname: builtins.toFile "pyproject.toml" '' 12 + [project] 13 + name = "${pname}" 14 + version = "1.0.0" 15 + ''; 16 + 17 + # generates source for a python project 18 + projectSource = pname: runCommand "my-project-source" {} '' 19 + mkdir -p $out/src 20 + cp ${pyprojectToml pname} $out/pyproject.toml 21 + touch $out/src/__init__.py 22 + ''; 23 + 24 + # helper to reduce boilerplate 25 + generatePythonPackage = args: pythonPkgs.buildPythonPackage ( 26 + { 27 + version = "1.0.0"; 28 + src = runCommand "my-project-source" {} '' 29 + mkdir -p $out/src 30 + cp ${pyprojectToml args.pname} $out/pyproject.toml 31 + touch $out/src/__init__.py 32 + ''; 33 + pyproject = true; 34 + catchConflicts = true; 35 + buildInputs = [ pythonPkgs.setuptools ]; 36 + } 37 + // args 38 + ); 39 + 40 + # in order to test for a failing build, wrap it in a shell script 41 + expectFailure = build: errorMsg: build.overrideDerivation (old: { 42 + builder = writeShellScript "test-for-failure" '' 43 + export PATH=${coreutils}/bin:${gnugrep}/bin:$PATH 44 + ${old.builder} "$@" > ./log 2>&1 45 + status=$? 46 + cat ./log 47 + if [ $status -eq 0 ] || ! grep -q "${errorMsg}" ./log; then 48 + echo "The build should have failed with '${errorMsg}', but it didn't" 49 + exit 1 50 + else 51 + echo "The build failed as expected with: ${errorMsg}" 52 + mkdir -p $out 53 + fi 54 + ''; 55 + }); 56 + in { 57 + 58 + ### TEST CASES 59 + 60 + # Test case which must not trigger any conflicts. 61 + # This derivation has runtime dependencies on custom versions of multiple build tools. 62 + # This scenario is relevant for lang2nix tools which do not override the nixpkgs fix-point. 63 + # see https://github.com/NixOS/nixpkgs/issues/283695 64 + ignores-build-time-deps = 65 + generatePythonPackage { 66 + pname = "ignores-build-time-deps"; 67 + buildInputs = [ 68 + pythonPkgs.build 69 + pythonPkgs.packaging 70 + pythonPkgs.setuptools 71 + pythonPkgs.wheel 72 + ]; 73 + propagatedBuildInputs = [ 74 + # Add customized versions of build tools as runtime deps 75 + (customize pythonPkgs.packaging) 76 + (customize pythonPkgs.setuptools) 77 + (customize pythonPkgs.wheel) 78 + ]; 79 + }; 80 + 81 + # Simplest test case that should trigger a conflict 82 + catches-simple-conflict = let 83 + # this build must fail due to conflicts 84 + package = pythonPkgs.buildPythonPackage rec { 85 + pname = "catches-simple-conflict"; 86 + version = "0.0.0"; 87 + src = projectSource pname; 88 + pyproject = true; 89 + catchConflicts = true; 90 + buildInputs = [ 91 + pythonPkgs.setuptools 92 + ]; 93 + # depend on two different versions of packaging 94 + # (an actual runtime dependency conflict) 95 + propagatedBuildInputs = [ 96 + pythonPkgs.packaging 97 + (customize pythonPkgs.packaging) 98 + ]; 99 + }; 100 + in 101 + expectFailure package "Found duplicated packages in closure for dependency 'packaging'"; 102 + 103 + 104 + /* 105 + More complex test case with a transitive conflict 106 + 107 + Test sets up this dependency tree: 108 + 109 + toplevel 110 + ├── dep1 111 + │ └── leaf 112 + └── dep2 113 + └── leaf (customized version -> conflicting) 114 + */ 115 + catches-transitive-conflict = let 116 + # package depending on both dependency1 and dependency2 117 + toplevel = generatePythonPackage { 118 + pname = "catches-transitive-conflict"; 119 + propagatedBuildInputs = [ dep1 dep2 ]; 120 + }; 121 + # dep1 package depending on leaf 122 + dep1 = generatePythonPackage { 123 + pname = "dependency1"; 124 + propagatedBuildInputs = [ leaf ]; 125 + }; 126 + # dep2 package depending on conflicting version of leaf 127 + dep2 = generatePythonPackage { 128 + pname = "dependency2"; 129 + propagatedBuildInputs = [ (customize leaf) ]; 130 + }; 131 + # some leaf package 132 + leaf = generatePythonPackage { 133 + pname = "leaf"; 134 + }; 135 + in 136 + expectFailure toplevel "Found duplicated packages in closure for dependency 'leaf'"; 137 + }