Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement new command 'pip index versions' #8978

Merged
merged 6 commits into from
Jun 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions news/7975.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions src/pip/_internal/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
uranusjr marked this conversation as resolved.
Show resolved Hide resolved
)),
('wheel', CommandInfo(
'pip._internal.commands.wheel', 'WheelCommand',
'Build wheels from your requirements.',
Expand Down
143 changes: 143 additions & 0 deletions src/pip/_internal/commands/index.py
Original file line number Diff line number Diff line change
@@ -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 <package>
"""

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):
uranusjr marked this conversation as resolved.
Show resolved Hide resolved
# 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)
31 changes: 18 additions & 13 deletions src/pip/_internal/commands/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 ''
Expand All @@ -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

Expand Down
75 changes: 75 additions & 0 deletions tests/functional/test_index.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions tests/unit/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)


Expand Down