Coverage for pipxl/resolver.py: 97%

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

2#

3# SPDX-License-Identifier: MIT

4

5 from __future__ import annotations

6

7 import json

8 from pathlib import Path

9 from typing import Any

10

11 from pipxl.data import Environment, ReqFileEntry

12 from pipxl.pip_cli import pip_cli

13

14

15 def 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

23 def _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

38 def _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

51 def _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

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

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