|
| 1 | +import json |
| 2 | +import logging |
| 3 | +import os |
| 4 | +from dataclasses import dataclass, field |
| 5 | +from optparse import Values |
| 6 | +from typing import Any, Dict, List |
| 7 | + |
| 8 | +from pip._vendor.packaging.requirements import Requirement |
| 9 | + |
| 10 | +from pip._internal.cli import cmdoptions |
| 11 | +from pip._internal.cli.cmdoptions import make_target_python |
| 12 | +from pip._internal.cli.req_command import RequirementCommand, with_cleanup |
| 13 | +from pip._internal.cli.status_codes import SUCCESS |
| 14 | +from pip._internal.exceptions import CommandError |
| 15 | +from pip._internal.models.link import RequirementDownloadInfo |
| 16 | +from pip._internal.req.req_tracker import get_requirement_tracker |
| 17 | +from pip._internal.resolution.base import RequirementSetWithCandidates |
| 18 | +from pip._internal.utils.misc import ensure_dir, normalize_path, write_output |
| 19 | +from pip._internal.utils.temp_dir import TempDirectory |
| 20 | + |
| 21 | +logger = logging.getLogger(__name__) |
| 22 | + |
| 23 | + |
| 24 | +@dataclass |
| 25 | +class DownloadInfos: |
| 26 | + implicit_requirements: List[Requirement] = field(default_factory=list) |
| 27 | + resolution: Dict[str, RequirementDownloadInfo] = field(default_factory=dict) |
| 28 | + |
| 29 | + def as_basic_log(self) -> str: |
| 30 | + implicits = ", ".join(f"'{req}'" for req in self.implicit_requirements) |
| 31 | + resolved = "\n".join( |
| 32 | + f"{info.req}: {info.url}" for info in self.resolution.values() |
| 33 | + ) |
| 34 | + return "\n".join( |
| 35 | + [ |
| 36 | + f"Implicit requirements: {implicits}", |
| 37 | + "Resolution:", |
| 38 | + f"{resolved}", |
| 39 | + ] |
| 40 | + ) |
| 41 | + |
| 42 | + def as_json(self) -> Dict[str, Any]: |
| 43 | + return { |
| 44 | + "implicit_requirements": [str(req) for req in self.implicit_requirements], |
| 45 | + "resolution": { |
| 46 | + name: info.as_json() for name, info in self.resolution.items() |
| 47 | + }, |
| 48 | + } |
| 49 | + |
| 50 | + |
| 51 | +class ResolveCommand(RequirementCommand): |
| 52 | + """ |
| 53 | + Download packages from: |
| 54 | +
|
| 55 | + - PyPI (and other indexes) using requirement specifiers. |
| 56 | + - VCS project urls. |
| 57 | + - Local project directories. |
| 58 | + - Local or remote source archives. |
| 59 | +
|
| 60 | + pip also supports downloading from "requirements files", which provide |
| 61 | + an easy way to specify a whole environment to be downloaded. |
| 62 | + """ |
| 63 | + |
| 64 | + usage = """ |
| 65 | + %prog [options] <requirement specifier> [package-index-options] ... |
| 66 | + %prog [options] -r <requirements file> [package-index-options] ... |
| 67 | + %prog [options] <vcs project url> ... |
| 68 | + %prog [options] <local project path> ... |
| 69 | + %prog [options] <archive url/path> ...""" |
| 70 | + |
| 71 | + def add_options(self) -> None: |
| 72 | + self.cmd_opts.add_option(cmdoptions.constraints()) |
| 73 | + self.cmd_opts.add_option(cmdoptions.requirements()) |
| 74 | + self.cmd_opts.add_option(cmdoptions.no_deps()) |
| 75 | + self.cmd_opts.add_option(cmdoptions.global_options()) |
| 76 | + self.cmd_opts.add_option(cmdoptions.no_binary()) |
| 77 | + self.cmd_opts.add_option(cmdoptions.only_binary()) |
| 78 | + self.cmd_opts.add_option(cmdoptions.prefer_binary()) |
| 79 | + self.cmd_opts.add_option(cmdoptions.src()) |
| 80 | + self.cmd_opts.add_option(cmdoptions.pre()) |
| 81 | + self.cmd_opts.add_option(cmdoptions.require_hashes()) |
| 82 | + self.cmd_opts.add_option(cmdoptions.progress_bar()) |
| 83 | + self.cmd_opts.add_option(cmdoptions.no_build_isolation()) |
| 84 | + self.cmd_opts.add_option(cmdoptions.use_pep517()) |
| 85 | + self.cmd_opts.add_option(cmdoptions.no_use_pep517()) |
| 86 | + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) |
| 87 | + |
| 88 | + self.cmd_opts.add_option( |
| 89 | + "-d", |
| 90 | + "--dest", |
| 91 | + "--destination-dir", |
| 92 | + "--destination-directory", |
| 93 | + dest="download_dir", |
| 94 | + metavar="dir", |
| 95 | + default=os.curdir, |
| 96 | + help="Download packages into <dir>.", |
| 97 | + ) |
| 98 | + |
| 99 | + self.cmd_opts.add_option( |
| 100 | + "-o", |
| 101 | + "--json", |
| 102 | + "--json-output", |
| 103 | + "--json-output-file", |
| 104 | + dest="json_output_file", |
| 105 | + metavar="file", |
| 106 | + help="Print a JSON object representing the resolve into <file>.", |
| 107 | + ) |
| 108 | + |
| 109 | + cmdoptions.add_target_python_options(self.cmd_opts) |
| 110 | + |
| 111 | + index_opts = cmdoptions.make_option_group( |
| 112 | + cmdoptions.index_group, |
| 113 | + self.parser, |
| 114 | + ) |
| 115 | + |
| 116 | + self.parser.insert_option_group(0, index_opts) |
| 117 | + self.parser.insert_option_group(0, self.cmd_opts) |
| 118 | + |
| 119 | + @with_cleanup |
| 120 | + def run(self, options: Values, args: List[str]) -> int: |
| 121 | + |
| 122 | + options.ignore_installed = True |
| 123 | + # editable doesn't really make sense for `pip download`, but the bowels |
| 124 | + # of the RequirementSet code require that property. |
| 125 | + options.editables = [] |
| 126 | + |
| 127 | + cmdoptions.check_dist_restriction(options) |
| 128 | + |
| 129 | + options.download_dir = normalize_path(options.download_dir) |
| 130 | + ensure_dir(options.download_dir) |
| 131 | + |
| 132 | + session = self.get_default_session(options) |
| 133 | + |
| 134 | + target_python = make_target_python(options) |
| 135 | + finder = self._build_package_finder( |
| 136 | + options=options, |
| 137 | + session=session, |
| 138 | + target_python=target_python, |
| 139 | + ignore_requires_python=options.ignore_requires_python, |
| 140 | + ) |
| 141 | + |
| 142 | + req_tracker = self.enter_context(get_requirement_tracker()) |
| 143 | + |
| 144 | + directory = TempDirectory( |
| 145 | + delete=not options.no_clean, |
| 146 | + kind="download", |
| 147 | + globally_managed=True, |
| 148 | + ) |
| 149 | + |
| 150 | + reqs = self.get_requirements(args, options, finder, session) |
| 151 | + |
| 152 | + preparer = self.make_requirement_preparer( |
| 153 | + temp_build_dir=directory, |
| 154 | + options=options, |
| 155 | + req_tracker=req_tracker, |
| 156 | + session=session, |
| 157 | + finder=finder, |
| 158 | + download_dir=options.download_dir, |
| 159 | + use_user_site=False, |
| 160 | + ) |
| 161 | + |
| 162 | + resolver = self.make_resolver( |
| 163 | + preparer=preparer, |
| 164 | + finder=finder, |
| 165 | + options=options, |
| 166 | + ignore_requires_python=options.ignore_requires_python, |
| 167 | + py_version_info=options.python_version, |
| 168 | + avoid_wheel_downloads=True, |
| 169 | + ) |
| 170 | + |
| 171 | + self.trace_basic_info(finder) |
| 172 | + |
| 173 | + requirement_set = resolver.resolve(reqs, check_supported_wheels=True) |
| 174 | + |
| 175 | + downloaded: List[str] = [] |
| 176 | + for req in requirement_set.requirements.values(): |
| 177 | + # If this distribution was not already satisfied, that means we |
| 178 | + # downloaded it while executing this command. |
| 179 | + if req.satisfied_by is None: |
| 180 | + preparer.save_linked_requirement(req) |
| 181 | + assert req.name is not None |
| 182 | + downloaded.append(req.name) |
| 183 | + |
| 184 | + download_infos = DownloadInfos() |
| 185 | + if not isinstance(requirement_set, RequirementSetWithCandidates): |
| 186 | + raise CommandError( |
| 187 | + "The legacy resolver is being used via " |
| 188 | + "--use-deprecated=legacy-resolver." |
| 189 | + "The legacy resolver does not retain detailed dependency information, " |
| 190 | + "so `pip resolve` cannot be used with it. " |
| 191 | + ) |
| 192 | + for candidate in requirement_set.candidates.mapping.values(): |
| 193 | + # This will occur for the python version requirement, for example. |
| 194 | + if candidate.name not in requirement_set.requirements: |
| 195 | + assert tuple(candidate.iter_dependencies(with_requires=True)) == () |
| 196 | + download_infos.implicit_requirements.append( |
| 197 | + candidate.as_serializable_requirement() |
| 198 | + ) |
| 199 | + continue |
| 200 | + req = requirement_set.requirements[candidate.name] |
| 201 | + assert req.name is not None |
| 202 | + assert req.link is not None |
| 203 | + assert req.name not in download_infos.resolution |
| 204 | + |
| 205 | + dependencies: List[Requirement] = [] |
| 206 | + for maybe_dep in candidate.iter_dependencies(with_requires=True): |
| 207 | + if maybe_dep is None: |
| 208 | + continue |
| 209 | + maybe_req = maybe_dep.as_serializable_requirement() |
| 210 | + if maybe_req is None: |
| 211 | + continue |
| 212 | + dependencies.append(maybe_req) |
| 213 | + |
| 214 | + download_infos.resolution[ |
| 215 | + req.name |
| 216 | + ] = RequirementDownloadInfo.from_req_and_link_and_deps( |
| 217 | + req=candidate.as_serializable_requirement(), |
| 218 | + dependencies=dependencies, |
| 219 | + link=req.link, |
| 220 | + ) |
| 221 | + |
| 222 | + if downloaded: |
| 223 | + write_output("Successfully downloaded %s", " ".join(downloaded)) |
| 224 | + write_output(download_infos.as_basic_log()) |
| 225 | + if options.json_output_file: |
| 226 | + with open(options.json_output_file, "w") as f: |
| 227 | + json.dump(download_infos.as_json(), f, indent=4) |
| 228 | + |
| 229 | + return SUCCESS |
0 commit comments