Yi Kong | 878f994 | 2023-12-13 12:55:04 +0900 | [diff] [blame^] | 1 | import json |
| 2 | import logging |
| 3 | from optparse import Values |
| 4 | from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence, Tuple, cast |
| 5 | |
| 6 | from pip._vendor.packaging.utils import canonicalize_name |
| 7 | |
| 8 | from pip._internal.cli import cmdoptions |
| 9 | from pip._internal.cli.req_command import IndexGroupCommand |
| 10 | from pip._internal.cli.status_codes import SUCCESS |
| 11 | from pip._internal.exceptions import CommandError |
| 12 | from pip._internal.index.collector import LinkCollector |
| 13 | from pip._internal.index.package_finder import PackageFinder |
| 14 | from pip._internal.metadata import BaseDistribution, get_environment |
| 15 | from pip._internal.models.selection_prefs import SelectionPreferences |
| 16 | from pip._internal.network.session import PipSession |
| 17 | from pip._internal.utils.compat import stdlib_pkgs |
| 18 | from pip._internal.utils.misc import tabulate, write_output |
| 19 | |
| 20 | if TYPE_CHECKING: |
| 21 | from pip._internal.metadata.base import DistributionVersion |
| 22 | |
| 23 | class _DistWithLatestInfo(BaseDistribution): |
| 24 | """Give the distribution object a couple of extra fields. |
| 25 | |
| 26 | These will be populated during ``get_outdated()``. This is dirty but |
| 27 | makes the rest of the code much cleaner. |
| 28 | """ |
| 29 | |
| 30 | latest_version: DistributionVersion |
| 31 | latest_filetype: str |
| 32 | |
| 33 | _ProcessedDists = Sequence[_DistWithLatestInfo] |
| 34 | |
| 35 | |
| 36 | logger = logging.getLogger(__name__) |
| 37 | |
| 38 | |
| 39 | class ListCommand(IndexGroupCommand): |
| 40 | """ |
| 41 | List installed packages, including editables. |
| 42 | |
| 43 | Packages are listed in a case-insensitive sorted order. |
| 44 | """ |
| 45 | |
| 46 | ignore_require_venv = True |
| 47 | usage = """ |
| 48 | %prog [options]""" |
| 49 | |
| 50 | def add_options(self) -> None: |
| 51 | self.cmd_opts.add_option( |
| 52 | "-o", |
| 53 | "--outdated", |
| 54 | action="store_true", |
| 55 | default=False, |
| 56 | help="List outdated packages", |
| 57 | ) |
| 58 | self.cmd_opts.add_option( |
| 59 | "-u", |
| 60 | "--uptodate", |
| 61 | action="store_true", |
| 62 | default=False, |
| 63 | help="List uptodate packages", |
| 64 | ) |
| 65 | self.cmd_opts.add_option( |
| 66 | "-e", |
| 67 | "--editable", |
| 68 | action="store_true", |
| 69 | default=False, |
| 70 | help="List editable projects.", |
| 71 | ) |
| 72 | self.cmd_opts.add_option( |
| 73 | "-l", |
| 74 | "--local", |
| 75 | action="store_true", |
| 76 | default=False, |
| 77 | help=( |
| 78 | "If in a virtualenv that has global access, do not list " |
| 79 | "globally-installed packages." |
| 80 | ), |
| 81 | ) |
| 82 | self.cmd_opts.add_option( |
| 83 | "--user", |
| 84 | dest="user", |
| 85 | action="store_true", |
| 86 | default=False, |
| 87 | help="Only output packages installed in user-site.", |
| 88 | ) |
| 89 | self.cmd_opts.add_option(cmdoptions.list_path()) |
| 90 | self.cmd_opts.add_option( |
| 91 | "--pre", |
| 92 | action="store_true", |
| 93 | default=False, |
| 94 | help=( |
| 95 | "Include pre-release and development versions. By default, " |
| 96 | "pip only finds stable versions." |
| 97 | ), |
| 98 | ) |
| 99 | |
| 100 | self.cmd_opts.add_option( |
| 101 | "--format", |
| 102 | action="store", |
| 103 | dest="list_format", |
| 104 | default="columns", |
| 105 | choices=("columns", "freeze", "json"), |
| 106 | help="Select the output format among: columns (default), freeze, or json", |
| 107 | ) |
| 108 | |
| 109 | self.cmd_opts.add_option( |
| 110 | "--not-required", |
| 111 | action="store_true", |
| 112 | dest="not_required", |
| 113 | help="List packages that are not dependencies of installed packages.", |
| 114 | ) |
| 115 | |
| 116 | self.cmd_opts.add_option( |
| 117 | "--exclude-editable", |
| 118 | action="store_false", |
| 119 | dest="include_editable", |
| 120 | help="Exclude editable package from output.", |
| 121 | ) |
| 122 | self.cmd_opts.add_option( |
| 123 | "--include-editable", |
| 124 | action="store_true", |
| 125 | dest="include_editable", |
| 126 | help="Include editable package from output.", |
| 127 | default=True, |
| 128 | ) |
| 129 | self.cmd_opts.add_option(cmdoptions.list_exclude()) |
| 130 | index_opts = cmdoptions.make_option_group(cmdoptions.index_group, self.parser) |
| 131 | |
| 132 | self.parser.insert_option_group(0, index_opts) |
| 133 | self.parser.insert_option_group(0, self.cmd_opts) |
| 134 | |
| 135 | def _build_package_finder( |
| 136 | self, options: Values, session: PipSession |
| 137 | ) -> PackageFinder: |
| 138 | """ |
| 139 | Create a package finder appropriate to this list command. |
| 140 | """ |
| 141 | link_collector = LinkCollector.create(session, options=options) |
| 142 | |
| 143 | # Pass allow_yanked=False to ignore yanked versions. |
| 144 | selection_prefs = SelectionPreferences( |
| 145 | allow_yanked=False, |
| 146 | allow_all_prereleases=options.pre, |
| 147 | ) |
| 148 | |
| 149 | return PackageFinder.create( |
| 150 | link_collector=link_collector, |
| 151 | selection_prefs=selection_prefs, |
| 152 | use_deprecated_html5lib="html5lib" in options.deprecated_features_enabled, |
| 153 | ) |
| 154 | |
| 155 | def run(self, options: Values, args: List[str]) -> int: |
| 156 | if options.outdated and options.uptodate: |
| 157 | raise CommandError("Options --outdated and --uptodate cannot be combined.") |
| 158 | |
| 159 | cmdoptions.check_list_path_option(options) |
| 160 | |
| 161 | skip = set(stdlib_pkgs) |
| 162 | if options.excludes: |
| 163 | skip.update(canonicalize_name(n) for n in options.excludes) |
| 164 | |
| 165 | packages: "_ProcessedDists" = [ |
| 166 | cast("_DistWithLatestInfo", d) |
| 167 | for d in get_environment(options.path).iter_installed_distributions( |
| 168 | local_only=options.local, |
| 169 | user_only=options.user, |
| 170 | editables_only=options.editable, |
| 171 | include_editables=options.include_editable, |
| 172 | skip=skip, |
| 173 | ) |
| 174 | ] |
| 175 | |
| 176 | # get_not_required must be called firstly in order to find and |
| 177 | # filter out all dependencies correctly. Otherwise a package |
| 178 | # can't be identified as requirement because some parent packages |
| 179 | # could be filtered out before. |
| 180 | if options.not_required: |
| 181 | packages = self.get_not_required(packages, options) |
| 182 | |
| 183 | if options.outdated: |
| 184 | packages = self.get_outdated(packages, options) |
| 185 | elif options.uptodate: |
| 186 | packages = self.get_uptodate(packages, options) |
| 187 | |
| 188 | self.output_package_listing(packages, options) |
| 189 | return SUCCESS |
| 190 | |
| 191 | def get_outdated( |
| 192 | self, packages: "_ProcessedDists", options: Values |
| 193 | ) -> "_ProcessedDists": |
| 194 | return [ |
| 195 | dist |
| 196 | for dist in self.iter_packages_latest_infos(packages, options) |
| 197 | if dist.latest_version > dist.version |
| 198 | ] |
| 199 | |
| 200 | def get_uptodate( |
| 201 | self, packages: "_ProcessedDists", options: Values |
| 202 | ) -> "_ProcessedDists": |
| 203 | return [ |
| 204 | dist |
| 205 | for dist in self.iter_packages_latest_infos(packages, options) |
| 206 | if dist.latest_version == dist.version |
| 207 | ] |
| 208 | |
| 209 | def get_not_required( |
| 210 | self, packages: "_ProcessedDists", options: Values |
| 211 | ) -> "_ProcessedDists": |
| 212 | dep_keys = { |
| 213 | canonicalize_name(dep.name) |
| 214 | for dist in packages |
| 215 | for dep in (dist.iter_dependencies() or ()) |
| 216 | } |
| 217 | |
| 218 | # Create a set to remove duplicate packages, and cast it to a list |
| 219 | # to keep the return type consistent with get_outdated and |
| 220 | # get_uptodate |
| 221 | return list({pkg for pkg in packages if pkg.canonical_name not in dep_keys}) |
| 222 | |
| 223 | def iter_packages_latest_infos( |
| 224 | self, packages: "_ProcessedDists", options: Values |
| 225 | ) -> Iterator["_DistWithLatestInfo"]: |
| 226 | with self._build_session(options) as session: |
| 227 | finder = self._build_package_finder(options, session) |
| 228 | |
| 229 | def latest_info( |
| 230 | dist: "_DistWithLatestInfo", |
| 231 | ) -> Optional["_DistWithLatestInfo"]: |
| 232 | all_candidates = finder.find_all_candidates(dist.canonical_name) |
| 233 | if not options.pre: |
| 234 | # Remove prereleases |
| 235 | all_candidates = [ |
| 236 | candidate |
| 237 | for candidate in all_candidates |
| 238 | if not candidate.version.is_prerelease |
| 239 | ] |
| 240 | |
| 241 | evaluator = finder.make_candidate_evaluator( |
| 242 | project_name=dist.canonical_name, |
| 243 | ) |
| 244 | best_candidate = evaluator.sort_best_candidate(all_candidates) |
| 245 | if best_candidate is None: |
| 246 | return None |
| 247 | |
| 248 | remote_version = best_candidate.version |
| 249 | if best_candidate.link.is_wheel: |
| 250 | typ = "wheel" |
| 251 | else: |
| 252 | typ = "sdist" |
| 253 | dist.latest_version = remote_version |
| 254 | dist.latest_filetype = typ |
| 255 | return dist |
| 256 | |
| 257 | for dist in map(latest_info, packages): |
| 258 | if dist is not None: |
| 259 | yield dist |
| 260 | |
| 261 | def output_package_listing( |
| 262 | self, packages: "_ProcessedDists", options: Values |
| 263 | ) -> None: |
| 264 | packages = sorted( |
| 265 | packages, |
| 266 | key=lambda dist: dist.canonical_name, |
| 267 | ) |
| 268 | if options.list_format == "columns" and packages: |
| 269 | data, header = format_for_columns(packages, options) |
| 270 | self.output_package_listing_columns(data, header) |
| 271 | elif options.list_format == "freeze": |
| 272 | for dist in packages: |
| 273 | if options.verbose >= 1: |
| 274 | write_output( |
| 275 | "%s==%s (%s)", dist.raw_name, dist.version, dist.location |
| 276 | ) |
| 277 | else: |
| 278 | write_output("%s==%s", dist.raw_name, dist.version) |
| 279 | elif options.list_format == "json": |
| 280 | write_output(format_for_json(packages, options)) |
| 281 | |
| 282 | def output_package_listing_columns( |
| 283 | self, data: List[List[str]], header: List[str] |
| 284 | ) -> None: |
| 285 | # insert the header first: we need to know the size of column names |
| 286 | if len(data) > 0: |
| 287 | data.insert(0, header) |
| 288 | |
| 289 | pkg_strings, sizes = tabulate(data) |
| 290 | |
| 291 | # Create and add a separator. |
| 292 | if len(data) > 0: |
| 293 | pkg_strings.insert(1, " ".join(map(lambda x: "-" * x, sizes))) |
| 294 | |
| 295 | for val in pkg_strings: |
| 296 | write_output(val) |
| 297 | |
| 298 | |
| 299 | def format_for_columns( |
| 300 | pkgs: "_ProcessedDists", options: Values |
| 301 | ) -> Tuple[List[List[str]], List[str]]: |
| 302 | """ |
| 303 | Convert the package data into something usable |
| 304 | by output_package_listing_columns. |
| 305 | """ |
| 306 | header = ["Package", "Version"] |
| 307 | |
| 308 | running_outdated = options.outdated |
| 309 | if running_outdated: |
| 310 | header.extend(["Latest", "Type"]) |
| 311 | |
| 312 | has_editables = any(x.editable for x in pkgs) |
| 313 | if has_editables: |
| 314 | header.append("Editable project location") |
| 315 | |
| 316 | if options.verbose >= 1: |
| 317 | header.append("Location") |
| 318 | if options.verbose >= 1: |
| 319 | header.append("Installer") |
| 320 | |
| 321 | data = [] |
| 322 | for proj in pkgs: |
| 323 | # if we're working on the 'outdated' list, separate out the |
| 324 | # latest_version and type |
| 325 | row = [proj.raw_name, str(proj.version)] |
| 326 | |
| 327 | if running_outdated: |
| 328 | row.append(str(proj.latest_version)) |
| 329 | row.append(proj.latest_filetype) |
| 330 | |
| 331 | if has_editables: |
| 332 | row.append(proj.editable_project_location or "") |
| 333 | |
| 334 | if options.verbose >= 1: |
| 335 | row.append(proj.location or "") |
| 336 | if options.verbose >= 1: |
| 337 | row.append(proj.installer) |
| 338 | |
| 339 | data.append(row) |
| 340 | |
| 341 | return data, header |
| 342 | |
| 343 | |
| 344 | def format_for_json(packages: "_ProcessedDists", options: Values) -> str: |
| 345 | data = [] |
| 346 | for dist in packages: |
| 347 | info = { |
| 348 | "name": dist.raw_name, |
| 349 | "version": str(dist.version), |
| 350 | } |
| 351 | if options.verbose >= 1: |
| 352 | info["location"] = dist.location or "" |
| 353 | info["installer"] = dist.installer |
| 354 | if options.outdated: |
| 355 | info["latest_version"] = str(dist.latest_version) |
| 356 | info["latest_filetype"] = dist.latest_filetype |
| 357 | editable_project_location = dist.editable_project_location |
| 358 | if editable_project_location: |
| 359 | info["editable_project_location"] = editable_project_location |
| 360 | data.append(info) |
| 361 | return json.dumps(data) |