Coverage for pipxl/resolver.py: 97%

52 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-28 21:32 +0100

1# SPDX-FileCopyrightText: 2022-present Jeroen van Zundert <mail@jeroenvanzundert.nl> 

2# 

3# SPDX-License-Identifier: MIT 

4 

5from __future__ import annotations 

6 

7import json 

8from pathlib import Path 

9from typing import Any 

10 

11from pipxl.data import Environment, ReqFileEntry 

12from pipxl.pip_cli import pip_cli 

13 

14 

15def pip_resolve( 

16 files_in: list[Path] | None = None, package_spec: list[str] | None = None, no_deps: bool = False 

17) -> tuple[list[ReqFileEntry], Environment]: 

18 target = _pip_install_target_arg(files_in, package_spec) 

19 json_report = _pip_install_fresh_dryrun(target, no_deps) 

20 return _parse_pip_install_json_report(json_report) 

21 

22 

23def _pip_install_target_arg(files_in: list[Path] | None = None, package_spec: list[str] | None = None) -> list[str]: 

24 if (files_in is None) and (package_spec is None): 

25 raise Exception("At least one files and/or package specification needs to be provided") 

26 

27 file_arg = [] 

28 if (files_in is not None) and len(files_in): 

29 for file in files_in: 

30 file_arg.extend(["-r", f"{str(file)}"]) 

31 

32 package_name_arg = [] if package_spec is None else package_spec 

33 

34 arg = file_arg + package_name_arg 

35 return arg 

36 

37 

38def _pip_install_fresh_dryrun(target: list[str], no_deps: bool = False) -> dict[str, Any]: 

39 cmd = ["install", "--ignore-installed", "--dry-run", "--report", "-", "--quiet"] 

40 if no_deps: 

41 cmd += ["--no-deps"] 

42 

43 output = pip_cli(cmd + target) 

44 if output.returncode != 0: 44 ↛ 45line 44 didn't jump to line 45, because the condition on line 44 was never true

45 raise Exception(output.stderr) 

46 js = json.loads(output.stdout) 

47 assert isinstance(js, dict) 

48 return js 

49 

50 

51def _parse_pip_install_json_report(js: dict[str, Any]) -> tuple[list[ReqFileEntry], Environment]: 

52 # first collect for each package all its dependencies 

53 deptree = dict() 

54 for package in js["install"]: 

55 # the raw version includes version and platform specifiers 

56 # example from httpx: 'certifi', 'sniffio', 'rfc3986[idna2008] (<2,>=1.3)', 'httpcore (<0.16.0,>=0.15.0)' 

57 # We store this as a dict, with the key being the name, for easy reference, and the value the full string 

58 meta = package["metadata"] 

59 if "requires_dist" in meta: 

60 deptree[meta["name"]] = {_package_name_from_requires_dist_string(s): s for s in meta["requires_dist"]} 

61 else: 

62 deptree[meta["name"]] = dict() 

63 

64 # traverse through list of packages to get version and required_by 

65 out: list[ReqFileEntry] = [] 

66 for package in js["install"]: 

67 meta = package["metadata"] 

68 

69 if "requires_dist" in meta: 

70 requires = {_package_name_from_requires_dist_string(s): s for s in meta["requires_dist"]} 

71 else: 

72 requires = {} 

73 

74 req_by = { 

75 potential_dep: reqs[meta["name"]] for potential_dep, reqs in deptree.items() if meta["name"] in reqs.keys() 

76 } 

77 archive_info = package["download_info"].get("archive_info") 

78 

79 out.append( 

80 ReqFileEntry( 

81 name=meta["name"], 

82 version=meta["version"], 

83 requires=requires, 

84 required_by=req_by, 

85 url=package["download_info"]["url"], 

86 hash=archive_info["hash"] if archive_info is not None else None, 

87 requested=package["requested"], 

88 license=package["metadata"].get("license", None), 

89 ) 

90 ) 

91 

92 # parse environment 

93 env_keys = [ 

94 "platform_python_implementation", 

95 "implementation_version", 

96 "platform_system", 

97 "platform_release", 

98 "platform_machine", 

99 ] 

100 env = {k: v for k, v in js["environment"].items() if k in env_keys} 

101 env = {"pip_version": js["pip_version"]} | env 

102 return out, Environment(**env) 

103 

104 

105def _package_name_from_requires_dist_string(s: str) -> str: 

106 return s.split()[0].split("[")[0]