diff --git a/news/7975.feature.rst b/news/7975.feature.rst new file mode 100644 index 00000000000..b0638939b56 --- /dev/null +++ b/news/7975.feature.rst @@ -0,0 +1,2 @@ +Add new subcommand ``pip index`` used to interact with indexes, and implement +``pip index version`` to list available versions of a package. diff --git a/src/pip/_internal/commands/__init__.py b/src/pip/_internal/commands/__init__.py index 31c985fdca5..e1fb8788428 100644 --- a/src/pip/_internal/commands/__init__.py +++ b/src/pip/_internal/commands/__init__.py @@ -59,6 +59,10 @@ 'pip._internal.commands.cache', 'CacheCommand', "Inspect and manage pip's wheel cache.", )), + ('index', CommandInfo( + 'pip._internal.commands.index', 'IndexCommand', + "Inspect information available from package indexes.", + )), ('wheel', CommandInfo( 'pip._internal.commands.wheel', 'WheelCommand', 'Build wheels from your requirements.', diff --git a/src/pip/_internal/commands/index.py b/src/pip/_internal/commands/index.py new file mode 100644 index 00000000000..4bfc4e9e3f3 --- /dev/null +++ b/src/pip/_internal/commands/index.py @@ -0,0 +1,143 @@ +import logging +from optparse import Values +from typing import Any, Iterable, List, Optional, Union + +from pip._vendor.packaging.version import LegacyVersion, Version + +from pip._internal.cli import cmdoptions +from pip._internal.cli.req_command import IndexGroupCommand +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.commands.search import print_dist_installation_info +from pip._internal.exceptions import CommandError, DistributionNotFound, PipError +from pip._internal.index.collector import LinkCollector +from pip._internal.index.package_finder import PackageFinder +from pip._internal.models.selection_prefs import SelectionPreferences +from pip._internal.models.target_python import TargetPython +from pip._internal.network.session import PipSession +from pip._internal.utils.misc import write_output + +logger = logging.getLogger(__name__) + + +class IndexCommand(IndexGroupCommand): + """ + Inspect information available from package indexes. + """ + + usage = """ + %prog versions + """ + + def add_options(self): + # type: () -> None + cmdoptions.add_target_python_options(self.cmd_opts) + + self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) + self.cmd_opts.add_option(cmdoptions.pre()) + self.cmd_opts.add_option(cmdoptions.no_binary()) + self.cmd_opts.add_option(cmdoptions.only_binary()) + + index_opts = cmdoptions.make_option_group( + cmdoptions.index_group, + self.parser, + ) + + self.parser.insert_option_group(0, index_opts) + self.parser.insert_option_group(0, self.cmd_opts) + + def run(self, options, args): + # type: (Values, List[Any]) -> int + handlers = { + "versions": self.get_available_package_versions, + } + + logger.warning( + "pip index is currently an experimental command. " + "It may be removed/changed in a future release " + "without prior warning." + ) + + # Determine action + if not args or args[0] not in handlers: + logger.error( + "Need an action (%s) to perform.", + ", ".join(sorted(handlers)), + ) + return ERROR + + action = args[0] + + # Error handling happens here, not in the action-handlers. + try: + handlers[action](options, args[1:]) + except PipError as e: + logger.error(e.args[0]) + return ERROR + + return SUCCESS + + def _build_package_finder( + self, + options, # type: Values + session, # type: PipSession + target_python=None, # type: Optional[TargetPython] + ignore_requires_python=None, # type: Optional[bool] + ): + # type: (...) -> PackageFinder + """ + Create a package finder appropriate to the index command. + """ + link_collector = LinkCollector.create(session, options=options) + + # Pass allow_yanked=False to ignore yanked versions. + selection_prefs = SelectionPreferences( + allow_yanked=False, + allow_all_prereleases=options.pre, + ignore_requires_python=ignore_requires_python, + ) + + return PackageFinder.create( + link_collector=link_collector, + selection_prefs=selection_prefs, + target_python=target_python, + ) + + def get_available_package_versions(self, options, args): + # type: (Values, List[Any]) -> None + if len(args) != 1: + raise CommandError('You need to specify exactly one argument') + + target_python = cmdoptions.make_target_python(options) + query = args[0] + + with self._build_session(options) as session: + finder = self._build_package_finder( + options=options, + session=session, + target_python=target_python, + ignore_requires_python=options.ignore_requires_python, + ) + + versions: Iterable[Union[LegacyVersion, Version]] = ( + candidate.version + for candidate in finder.find_all_candidates(query) + ) + + if not options.pre: + # Remove prereleases + versions = (version for version in versions + if not version.is_prerelease) + versions = set(versions) + + if not versions: + raise DistributionNotFound( + 'No matching distribution found for {}'.format(query)) + + formatted_versions = [str(ver) for ver in sorted( + versions, reverse=True)] + latest = formatted_versions[0] + + write_output('{} ({})'.format(query, latest)) + write_output('Available versions: {}'.format( + ', '.join(formatted_versions))) + print_dist_installation_info(query, latest) diff --git a/src/pip/_internal/commands/search.py b/src/pip/_internal/commands/search.py index d66e823471c..3bfd29afce9 100644 --- a/src/pip/_internal/commands/search.py +++ b/src/pip/_internal/commands/search.py @@ -114,6 +114,23 @@ def transform_hits(hits): return list(packages.values()) +def print_dist_installation_info(name, latest): + # type: (str, str) -> None + env = get_default_environment() + dist = env.get_distribution(name) + if dist is not None: + with indent_log(): + if dist.version == latest: + write_output('INSTALLED: %s (latest)', dist.version) + else: + write_output('INSTALLED: %s', dist.version) + if parse_version(latest).pre: + write_output('LATEST: %s (pre-release; install' + ' with "pip install --pre")', latest) + else: + write_output('LATEST: %s', latest) + + def print_results(hits, name_column_width=None, terminal_width=None): # type: (List[TransformedHit], Optional[int], Optional[int]) -> None if not hits: @@ -124,7 +141,6 @@ def print_results(hits, name_column_width=None, terminal_width=None): for hit in hits ]) + 4 - env = get_default_environment() for hit in hits: name = hit['name'] summary = hit['summary'] or '' @@ -141,18 +157,7 @@ def print_results(hits, name_column_width=None, terminal_width=None): line = f'{name_latest:{name_column_width}} - {summary}' try: write_output(line) - dist = env.get_distribution(name) - if dist is not None: - with indent_log(): - if dist.version == latest: - write_output('INSTALLED: %s (latest)', dist.version) - else: - write_output('INSTALLED: %s', dist.version) - if parse_version(latest).pre: - write_output('LATEST: %s (pre-release; install' - ' with "pip install --pre")', latest) - else: - write_output('LATEST: %s', latest) + print_dist_installation_info(name, latest) except UnicodeEncodeError: pass diff --git a/tests/functional/test_index.py b/tests/functional/test_index.py new file mode 100644 index 00000000000..004e672a50a --- /dev/null +++ b/tests/functional/test_index.py @@ -0,0 +1,75 @@ +import pytest + +from pip._internal.cli.status_codes import ERROR, SUCCESS +from pip._internal.commands import create_command + + +@pytest.mark.network +def test_list_all_versions_basic_search(script): + """ + End to end test of index versions command. + """ + output = script.pip('index', 'versions', 'pip', allow_stderr_warning=True) + assert 'Available versions:' in output.stdout + assert ( + '20.2.3, 20.2.2, 20.2.1, 20.2, 20.1.1, 20.1, 20.0.2' + ', 20.0.1, 19.3.1, 19.3, 19.2.3, 19.2.2, 19.2.1, 19.2, 19.1.1' + ', 19.1, 19.0.3, 19.0.2, 19.0.1, 19.0, 18.1, 18.0, 10.0.1, 10.0.0, ' + '9.0.3, 9.0.2, 9.0.1, 9.0.0, 8.1.2, 8.1.1, ' + '8.1.0, 8.0.3, 8.0.2, 8.0.1, 8.0.0, 7.1.2, 7.1.1, 7.1.0, 7.0.3, ' + '7.0.2, 7.0.1, 7.0.0, 6.1.1, 6.1.0, 6.0.8, 6.0.7, 6.0.6, 6.0.5, ' + '6.0.4, 6.0.3, 6.0.2, 6.0.1, 6.0, 1.5.6, 1.5.5, 1.5.4, 1.5.3, ' + '1.5.2, 1.5.1, 1.5, 1.4.1, 1.4, 1.3.1, 1.3, 1.2.1, 1.2, 1.1, 1.0.2,' + ' 1.0.1, 1.0, 0.8.3, 0.8.2, 0.8.1, 0.8, 0.7.2, 0.7.1, 0.7, 0.6.3, ' + '0.6.2, 0.6.1, 0.6, 0.5.1, 0.5, 0.4, 0.3.1, ' + '0.3, 0.2.1, 0.2' in output.stdout + ) + + +@pytest.mark.network +def test_list_all_versions_search_with_pre(script): + """ + See that adding the --pre flag adds pre-releases + """ + output = script.pip( + 'index', 'versions', 'pip', '--pre', allow_stderr_warning=True) + assert 'Available versions:' in output.stdout + assert ( + '20.2.3, 20.2.2, 20.2.1, 20.2, 20.2b1, 20.1.1, 20.1, 20.1b1, 20.0.2' + ', 20.0.1, 19.3.1, 19.3, 19.2.3, 19.2.2, 19.2.1, 19.2, 19.1.1' + ', 19.1, 19.0.3, 19.0.2, 19.0.1, 19.0, 18.1, 18.0, 10.0.1, 10.0.0, ' + '10.0.0b2, 10.0.0b1, 9.0.3, 9.0.2, 9.0.1, 9.0.0, 8.1.2, 8.1.1, ' + '8.1.0, 8.0.3, 8.0.2, 8.0.1, 8.0.0, 7.1.2, 7.1.1, 7.1.0, 7.0.3, ' + '7.0.2, 7.0.1, 7.0.0, 6.1.1, 6.1.0, 6.0.8, 6.0.7, 6.0.6, 6.0.5, ' + '6.0.4, 6.0.3, 6.0.2, 6.0.1, 6.0, 1.5.6, 1.5.5, 1.5.4, 1.5.3, ' + '1.5.2, 1.5.1, 1.5, 1.4.1, 1.4, 1.3.1, 1.3, 1.2.1, 1.2, 1.1, 1.0.2,' + ' 1.0.1, 1.0, 0.8.3, 0.8.2, 0.8.1, 0.8, 0.7.2, 0.7.1, 0.7, 0.6.3, ' + '0.6.2, 0.6.1, 0.6, 0.5.1, 0.5, 0.4, 0.3.1, ' + '0.3, 0.2.1, 0.2' in output.stdout + ) + + +@pytest.mark.network +def test_list_all_versions_returns_no_matches_found_when_name_not_exact(): + """ + Test that non exact name do not match + """ + command = create_command('index') + cmdline = "versions pand" + with command.main_context(): + options, args = command.parse_args(cmdline.split()) + status = command.run(options, args) + assert status == ERROR + + +@pytest.mark.network +def test_list_all_versions_returns_matches_found_when_name_is_exact(): + """ + Test that exact name matches + """ + command = create_command('index') + cmdline = "versions pandas" + with command.main_context(): + options, args = command.parse_args(cmdline.split()) + status = command.run(options, args) + assert status == SUCCESS diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index f34f7e5387b..cb144c5f6da 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -11,7 +11,8 @@ # These are the expected names of the commands whose classes inherit from # IndexGroupCommand. -EXPECTED_INDEX_GROUP_COMMANDS = ['download', 'install', 'list', 'wheel'] +EXPECTED_INDEX_GROUP_COMMANDS = [ + 'download', 'index', 'install', 'list', 'wheel'] def check_commands(pred, expected): @@ -49,7 +50,9 @@ def test_session_commands(): def is_session_command(command): return isinstance(command, SessionCommandMixin) - expected = ['download', 'install', 'list', 'search', 'uninstall', 'wheel'] + expected = [ + 'download', 'index', 'install', 'list', 'search', 'uninstall', 'wheel' + ] check_commands(is_session_command, expected)