Skip to content

Commit 3751878

Browse files
author
Noah Gorny
authored
Implement new command 'pip index versions'
1 parent a90dd11 commit 3751878

File tree

6 files changed

+247
-15
lines changed

6 files changed

+247
-15
lines changed

news/7975.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add new subcommand ``pip index`` used to interact with indexes, and implement
2+
``pip index version`` to list available versions of a package.

src/pip/_internal/commands/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
'pip._internal.commands.cache', 'CacheCommand',
6060
"Inspect and manage pip's wheel cache.",
6161
)),
62+
('index', CommandInfo(
63+
'pip._internal.commands.index', 'IndexCommand',
64+
"Inspect information available from package indexes.",
65+
)),
6266
('wheel', CommandInfo(
6367
'pip._internal.commands.wheel', 'WheelCommand',
6468
'Build wheels from your requirements.',

src/pip/_internal/commands/index.py

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import logging
2+
from optparse import Values
3+
from typing import Any, Iterable, List, Optional, Union
4+
5+
from pip._vendor.packaging.version import LegacyVersion, Version
6+
7+
from pip._internal.cli import cmdoptions
8+
from pip._internal.cli.req_command import IndexGroupCommand
9+
from pip._internal.cli.status_codes import ERROR, SUCCESS
10+
from pip._internal.commands.search import print_dist_installation_info
11+
from pip._internal.exceptions import CommandError, DistributionNotFound, PipError
12+
from pip._internal.index.collector import LinkCollector
13+
from pip._internal.index.package_finder import PackageFinder
14+
from pip._internal.models.selection_prefs import SelectionPreferences
15+
from pip._internal.models.target_python import TargetPython
16+
from pip._internal.network.session import PipSession
17+
from pip._internal.utils.misc import write_output
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class IndexCommand(IndexGroupCommand):
23+
"""
24+
Inspect information available from package indexes.
25+
"""
26+
27+
usage = """
28+
%prog versions <package>
29+
"""
30+
31+
def add_options(self):
32+
# type: () -> None
33+
cmdoptions.add_target_python_options(self.cmd_opts)
34+
35+
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
36+
self.cmd_opts.add_option(cmdoptions.pre())
37+
self.cmd_opts.add_option(cmdoptions.no_binary())
38+
self.cmd_opts.add_option(cmdoptions.only_binary())
39+
40+
index_opts = cmdoptions.make_option_group(
41+
cmdoptions.index_group,
42+
self.parser,
43+
)
44+
45+
self.parser.insert_option_group(0, index_opts)
46+
self.parser.insert_option_group(0, self.cmd_opts)
47+
48+
def run(self, options, args):
49+
# type: (Values, List[Any]) -> int
50+
handlers = {
51+
"versions": self.get_available_package_versions,
52+
}
53+
54+
logger.warning(
55+
"pip index is currently an experimental command. "
56+
"It may be removed/changed in a future release "
57+
"without prior warning."
58+
)
59+
60+
# Determine action
61+
if not args or args[0] not in handlers:
62+
logger.error(
63+
"Need an action (%s) to perform.",
64+
", ".join(sorted(handlers)),
65+
)
66+
return ERROR
67+
68+
action = args[0]
69+
70+
# Error handling happens here, not in the action-handlers.
71+
try:
72+
handlers[action](options, args[1:])
73+
except PipError as e:
74+
logger.error(e.args[0])
75+
return ERROR
76+
77+
return SUCCESS
78+
79+
def _build_package_finder(
80+
self,
81+
options, # type: Values
82+
session, # type: PipSession
83+
target_python=None, # type: Optional[TargetPython]
84+
ignore_requires_python=None, # type: Optional[bool]
85+
):
86+
# type: (...) -> PackageFinder
87+
"""
88+
Create a package finder appropriate to the index command.
89+
"""
90+
link_collector = LinkCollector.create(session, options=options)
91+
92+
# Pass allow_yanked=False to ignore yanked versions.
93+
selection_prefs = SelectionPreferences(
94+
allow_yanked=False,
95+
allow_all_prereleases=options.pre,
96+
ignore_requires_python=ignore_requires_python,
97+
)
98+
99+
return PackageFinder.create(
100+
link_collector=link_collector,
101+
selection_prefs=selection_prefs,
102+
target_python=target_python,
103+
)
104+
105+
def get_available_package_versions(self, options, args):
106+
# type: (Values, List[Any]) -> None
107+
if len(args) != 1:
108+
raise CommandError('You need to specify exactly one argument')
109+
110+
target_python = cmdoptions.make_target_python(options)
111+
query = args[0]
112+
113+
with self._build_session(options) as session:
114+
finder = self._build_package_finder(
115+
options=options,
116+
session=session,
117+
target_python=target_python,
118+
ignore_requires_python=options.ignore_requires_python,
119+
)
120+
121+
versions: Iterable[Union[LegacyVersion, Version]] = (
122+
candidate.version
123+
for candidate in finder.find_all_candidates(query)
124+
)
125+
126+
if not options.pre:
127+
# Remove prereleases
128+
versions = (version for version in versions
129+
if not version.is_prerelease)
130+
versions = set(versions)
131+
132+
if not versions:
133+
raise DistributionNotFound(
134+
'No matching distribution found for {}'.format(query))
135+
136+
formatted_versions = [str(ver) for ver in sorted(
137+
versions, reverse=True)]
138+
latest = formatted_versions[0]
139+
140+
write_output('{} ({})'.format(query, latest))
141+
write_output('Available versions: {}'.format(
142+
', '.join(formatted_versions)))
143+
print_dist_installation_info(query, latest)

src/pip/_internal/commands/search.py

+18-13
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ def transform_hits(hits):
114114
return list(packages.values())
115115

116116

117+
def print_dist_installation_info(name, latest):
118+
# type: (str, str) -> None
119+
env = get_default_environment()
120+
dist = env.get_distribution(name)
121+
if dist is not None:
122+
with indent_log():
123+
if dist.version == latest:
124+
write_output('INSTALLED: %s (latest)', dist.version)
125+
else:
126+
write_output('INSTALLED: %s', dist.version)
127+
if parse_version(latest).pre:
128+
write_output('LATEST: %s (pre-release; install'
129+
' with "pip install --pre")', latest)
130+
else:
131+
write_output('LATEST: %s', latest)
132+
133+
117134
def print_results(hits, name_column_width=None, terminal_width=None):
118135
# type: (List[TransformedHit], Optional[int], Optional[int]) -> None
119136
if not hits:
@@ -124,7 +141,6 @@ def print_results(hits, name_column_width=None, terminal_width=None):
124141
for hit in hits
125142
]) + 4
126143

127-
env = get_default_environment()
128144
for hit in hits:
129145
name = hit['name']
130146
summary = hit['summary'] or ''
@@ -141,18 +157,7 @@ def print_results(hits, name_column_width=None, terminal_width=None):
141157
line = f'{name_latest:{name_column_width}} - {summary}'
142158
try:
143159
write_output(line)
144-
dist = env.get_distribution(name)
145-
if dist is not None:
146-
with indent_log():
147-
if dist.version == latest:
148-
write_output('INSTALLED: %s (latest)', dist.version)
149-
else:
150-
write_output('INSTALLED: %s', dist.version)
151-
if parse_version(latest).pre:
152-
write_output('LATEST: %s (pre-release; install'
153-
' with "pip install --pre")', latest)
154-
else:
155-
write_output('LATEST: %s', latest)
160+
print_dist_installation_info(name, latest)
156161
except UnicodeEncodeError:
157162
pass
158163

tests/functional/test_index.py

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import pytest
2+
3+
from pip._internal.cli.status_codes import ERROR, SUCCESS
4+
from pip._internal.commands import create_command
5+
6+
7+
@pytest.mark.network
8+
def test_list_all_versions_basic_search(script):
9+
"""
10+
End to end test of index versions command.
11+
"""
12+
output = script.pip('index', 'versions', 'pip', allow_stderr_warning=True)
13+
assert 'Available versions:' in output.stdout
14+
assert (
15+
'20.2.3, 20.2.2, 20.2.1, 20.2, 20.1.1, 20.1, 20.0.2'
16+
', 20.0.1, 19.3.1, 19.3, 19.2.3, 19.2.2, 19.2.1, 19.2, 19.1.1'
17+
', 19.1, 19.0.3, 19.0.2, 19.0.1, 19.0, 18.1, 18.0, 10.0.1, 10.0.0, '
18+
'9.0.3, 9.0.2, 9.0.1, 9.0.0, 8.1.2, 8.1.1, '
19+
'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, '
20+
'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, '
21+
'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, '
22+
'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,'
23+
' 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, '
24+
'0.6.2, 0.6.1, 0.6, 0.5.1, 0.5, 0.4, 0.3.1, '
25+
'0.3, 0.2.1, 0.2' in output.stdout
26+
)
27+
28+
29+
@pytest.mark.network
30+
def test_list_all_versions_search_with_pre(script):
31+
"""
32+
See that adding the --pre flag adds pre-releases
33+
"""
34+
output = script.pip(
35+
'index', 'versions', 'pip', '--pre', allow_stderr_warning=True)
36+
assert 'Available versions:' in output.stdout
37+
assert (
38+
'20.2.3, 20.2.2, 20.2.1, 20.2, 20.2b1, 20.1.1, 20.1, 20.1b1, 20.0.2'
39+
', 20.0.1, 19.3.1, 19.3, 19.2.3, 19.2.2, 19.2.1, 19.2, 19.1.1'
40+
', 19.1, 19.0.3, 19.0.2, 19.0.1, 19.0, 18.1, 18.0, 10.0.1, 10.0.0, '
41+
'10.0.0b2, 10.0.0b1, 9.0.3, 9.0.2, 9.0.1, 9.0.0, 8.1.2, 8.1.1, '
42+
'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, '
43+
'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, '
44+
'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, '
45+
'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,'
46+
' 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, '
47+
'0.6.2, 0.6.1, 0.6, 0.5.1, 0.5, 0.4, 0.3.1, '
48+
'0.3, 0.2.1, 0.2' in output.stdout
49+
)
50+
51+
52+
@pytest.mark.network
53+
def test_list_all_versions_returns_no_matches_found_when_name_not_exact():
54+
"""
55+
Test that non exact name do not match
56+
"""
57+
command = create_command('index')
58+
cmdline = "versions pand"
59+
with command.main_context():
60+
options, args = command.parse_args(cmdline.split())
61+
status = command.run(options, args)
62+
assert status == ERROR
63+
64+
65+
@pytest.mark.network
66+
def test_list_all_versions_returns_matches_found_when_name_is_exact():
67+
"""
68+
Test that exact name matches
69+
"""
70+
command = create_command('index')
71+
cmdline = "versions pandas"
72+
with command.main_context():
73+
options, args = command.parse_args(cmdline.split())
74+
status = command.run(options, args)
75+
assert status == SUCCESS

tests/unit/test_commands.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212
# These are the expected names of the commands whose classes inherit from
1313
# IndexGroupCommand.
14-
EXPECTED_INDEX_GROUP_COMMANDS = ['download', 'install', 'list', 'wheel']
14+
EXPECTED_INDEX_GROUP_COMMANDS = [
15+
'download', 'index', 'install', 'list', 'wheel']
1516

1617

1718
def check_commands(pred, expected):
@@ -49,7 +50,9 @@ def test_session_commands():
4950
def is_session_command(command):
5051
return isinstance(command, SessionCommandMixin)
5152

52-
expected = ['download', 'install', 'list', 'search', 'uninstall', 'wheel']
53+
expected = [
54+
'download', 'index', 'install', 'list', 'search', 'uninstall', 'wheel'
55+
]
5356
check_commands(is_session_command, expected)
5457

5558

0 commit comments

Comments
 (0)