diff --git a/news/6638.feature b/news/6638.feature new file mode 100644 index 00000000000..f96b9133d29 --- /dev/null +++ b/news/6638.feature @@ -0,0 +1,2 @@ +Add a new command ``pip debug`` that can display e.g. the list of compatible +tags for the current Python. diff --git a/src/pip/_internal/cli/base_command.py b/src/pip/_internal/cli/base_command.py index 65842043373..53b4cf73aab 100644 --- a/src/pip/_internal/cli/base_command.py +++ b/src/pip/_internal/cli/base_command.py @@ -324,10 +324,7 @@ def _build_package_finder( self, options, # type: Values session, # type: PipSession - platform=None, # type: Optional[str] - py_version_info=None, # type: Optional[Tuple[int, ...]] - abi=None, # type: Optional[str] - implementation=None, # type: Optional[str] + target_python=None, # type: Optional[TargetPython] ignore_requires_python=None, # type: Optional[bool] ): # type: (...) -> PackageFinder @@ -346,13 +343,6 @@ def _build_package_finder( ignore_requires_python=ignore_requires_python, ) - target_python = TargetPython( - platform=platform, - py_version_info=py_version_info, - abi=abi, - implementation=implementation, - ) - return PackageFinder.create( search_scope=search_scope, selection_prefs=selection_prefs, diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index eba545c8509..ac41bd99bd4 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -22,6 +22,7 @@ from pip._internal.models.format_control import FormatControl from pip._internal.models.index import PyPI from pip._internal.models.search_scope import SearchScope +from pip._internal.models.target_python import TargetPython from pip._internal.utils.hashes import STRONG_HASHES from pip._internal.utils.misc import redact_password_from_url from pip._internal.utils.typing import MYPY_CHECK_RUNNING @@ -356,6 +357,7 @@ def find_links(): def make_search_scope(options, suppress_no_index=False): + # type: (Values, bool) -> SearchScope """ :param suppress_no_index: Whether to ignore the --no-index option when constructing the SearchScope object. @@ -600,6 +602,26 @@ def _handle_python_version(option, opt_str, value, parser): ) # type: Callable[..., Option] +def add_target_python_options(cmd_opts): + # type: (OptionGroup) -> None + cmd_opts.add_option(platform()) + cmd_opts.add_option(python_version()) + cmd_opts.add_option(implementation()) + cmd_opts.add_option(abi()) + + +def make_target_python(options): + # type: (Values) -> TargetPython + target_python = TargetPython( + platform=options.platform, + py_version_info=options.python_version, + abi=options.abi, + implementation=options.implementation, + ) + + return target_python + + def prefer_binary(): # type: () -> Option return Option( diff --git a/src/pip/_internal/cli/main_parser.py b/src/pip/_internal/cli/main_parser.py index 767f35d50ee..6d0b719aa42 100644 --- a/src/pip/_internal/cli/main_parser.py +++ b/src/pip/_internal/cli/main_parser.py @@ -4,7 +4,6 @@ import os import sys -from pip import __version__ from pip._internal.cli import cmdoptions from pip._internal.cli.parser import ( ConfigOptionParser, UpdatingDefaultsHelpFormatter, @@ -13,7 +12,7 @@ commands_dict, get_similar_commands, get_summaries, ) from pip._internal.exceptions import CommandError -from pip._internal.utils.misc import get_prog +from pip._internal.utils.misc import get_pip_version, get_prog from pip._internal.utils.typing import MYPY_CHECK_RUNNING if MYPY_CHECK_RUNNING: @@ -39,12 +38,7 @@ def create_main_parser(): parser = ConfigOptionParser(**parser_kw) parser.disable_interspersed_args() - pip_pkg_dir = os.path.abspath(os.path.join( - os.path.dirname(__file__), "..", "..", - )) - parser.version = 'pip %s from %s (python %s)' % ( - __version__, pip_pkg_dir, sys.version[:3], - ) + parser.version = get_pip_version() # add the general options gen_opts = cmdoptions.make_option_group(cmdoptions.general_group, parser) diff --git a/src/pip/_internal/commands/__init__.py b/src/pip/_internal/commands/__init__.py index 2e90db34f69..9e0ab86b9ca 100644 --- a/src/pip/_internal/commands/__init__.py +++ b/src/pip/_internal/commands/__init__.py @@ -5,6 +5,7 @@ from pip._internal.commands.completion import CompletionCommand from pip._internal.commands.configuration import ConfigurationCommand +from pip._internal.commands.debug import DebugCommand from pip._internal.commands.download import DownloadCommand from pip._internal.commands.freeze import FreezeCommand from pip._internal.commands.hash import HashCommand @@ -36,6 +37,7 @@ WheelCommand, HashCommand, CompletionCommand, + DebugCommand, HelpCommand, ] # type: List[Type[Command]] diff --git a/src/pip/_internal/commands/debug.py b/src/pip/_internal/commands/debug.py new file mode 100644 index 00000000000..99b5b946f8a --- /dev/null +++ b/src/pip/_internal/commands/debug.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import + +import logging +import sys + +from pip._internal.cli import cmdoptions +from pip._internal.cli.base_command import Command +from pip._internal.cli.cmdoptions import make_target_python +from pip._internal.cli.status_codes import SUCCESS +from pip._internal.utils.logging import indent_log +from pip._internal.utils.misc import get_pip_version +from pip._internal.utils.typing import MYPY_CHECK_RUNNING +from pip._internal.wheel import format_tag + +if MYPY_CHECK_RUNNING: + from typing import Any, List + from optparse import Values + +logger = logging.getLogger(__name__) + + +def show_value(name, value): + # type: (str, str) -> None + logger.info('{}: {}'.format(name, value)) + + +def show_sys_implementation(): + # type: () -> None + logger.info('sys.implementation:') + if hasattr(sys, 'implementation'): + implementation = sys.implementation # type: ignore + implementation_name = implementation.name + else: + implementation_name = '' + + with indent_log(): + show_value('name', implementation_name) + + +def show_tags(options): + # type: (Values) -> None + tag_limit = 10 + + target_python = make_target_python(options) + tags = target_python.get_tags() + + # Display the target options that were explicitly provided. + formatted_target = target_python.format_given() + suffix = '' + if formatted_target: + suffix = ' (target: {})'.format(formatted_target) + + msg = 'Compatible tags: {}{}'.format(len(tags), suffix) + logger.info(msg) + + if options.verbose < 1 and len(tags) > tag_limit: + tags_limited = True + tags = tags[:tag_limit] + else: + tags_limited = False + + with indent_log(): + for tag in tags: + logger.info(format_tag(tag)) + + if tags_limited: + msg = ( + '...\n' + '[First {tag_limit} tags shown. Pass --verbose to show all.]' + ).format(tag_limit=tag_limit) + logger.info(msg) + + +class DebugCommand(Command): + """ + Display debug information. + """ + + name = 'debug' + usage = """ + %prog """ + summary = 'Show information useful for debugging.' + ignore_require_venv = True + + def __init__(self, *args, **kw): + super(DebugCommand, self).__init__(*args, **kw) + + cmd_opts = self.cmd_opts + cmdoptions.add_target_python_options(cmd_opts) + self.parser.insert_option_group(0, cmd_opts) + + def run(self, options, args): + # type: (Values, List[Any]) -> int + show_value('pip version', get_pip_version()) + show_value('sys.version', sys.version) + show_value('sys.executable', sys.executable) + show_value('sys.platform', sys.platform) + show_sys_implementation() + + show_tags(options) + + return SUCCESS diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index abdd1c2d01b..5642b561758 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -5,6 +5,7 @@ from pip._internal.cli import cmdoptions from pip._internal.cli.base_command import RequirementCommand +from pip._internal.cli.cmdoptions import make_target_python from pip._internal.legacy_resolve import Resolver from pip._internal.operations.prepare import RequirementPreparer from pip._internal.req import RequirementSet @@ -69,10 +70,7 @@ def __init__(self, *args, **kw): help=("Download packages into ."), ) - cmd_opts.add_option(cmdoptions.platform()) - cmd_opts.add_option(cmdoptions.python_version()) - cmd_opts.add_option(cmdoptions.implementation()) - cmd_opts.add_option(cmdoptions.abi()) + cmdoptions.add_target_python_options(cmd_opts) index_opts = cmdoptions.make_option_group( cmdoptions.index_group, @@ -96,13 +94,11 @@ def run(self, options, args): ensure_dir(options.download_dir) with self._build_session(options) as session: + target_python = make_target_python(options) finder = self._build_package_finder( options=options, session=session, - platform=options.platform, - py_version_info=options.python_version, - abi=options.abi, - implementation=options.implementation, + target_python=target_python, ) build_delete = (not (options.no_clean or options.build_dir)) if options.cache_dir and not check_path_owner(options.cache_dir): diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 6898cb276db..9532ef88fe9 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -12,6 +12,7 @@ from pip._internal.cache import WheelCache from pip._internal.cli import cmdoptions from pip._internal.cli.base_command import RequirementCommand +from pip._internal.cli.cmdoptions import make_target_python from pip._internal.cli.status_codes import ERROR from pip._internal.exceptions import ( CommandError, InstallationError, PreviousBuildDirError, @@ -114,10 +115,7 @@ def __init__(self, *args, **kw): '. Use --upgrade to replace existing packages in ' 'with new versions.' ) - cmd_opts.add_option(cmdoptions.platform()) - cmd_opts.add_option(cmdoptions.python_version()) - cmd_opts.add_option(cmdoptions.implementation()) - cmd_opts.add_option(cmdoptions.abi()) + cmdoptions.add_target_python_options(cmd_opts) cmd_opts.add_option( '--user', @@ -285,13 +283,11 @@ def run(self, options, args): global_options = options.global_options or [] with self._build_session(options) as session: + target_python = make_target_python(options) finder = self._build_package_finder( options=options, session=session, - platform=options.platform, - py_version_info=options.python_version, - abi=options.abi, - implementation=options.implementation, + target_python=target_python, ignore_requires_python=options.ignore_requires_python, ) build_delete = (not (options.no_clean or options.build_dir)) diff --git a/src/pip/_internal/models/target_python.py b/src/pip/_internal/models/target_python.py index e10efb8ea32..7ad5786c435 100644 --- a/src/pip/_internal/models/target_python.py +++ b/src/pip/_internal/models/target_python.py @@ -5,7 +5,8 @@ from pip._internal.utils.typing import MYPY_CHECK_RUNNING if MYPY_CHECK_RUNNING: - from typing import Optional, Tuple + from typing import List, Optional, Tuple + from pip._internal.pep425tags import Pep425Tag class TargetPython(object): @@ -54,9 +55,32 @@ def __init__( self.py_version_info = py_version_info # This is used to cache the return value of get_tags(). - self._valid_tags = None + self._valid_tags = None # type: Optional[List[Pep425Tag]] + + def format_given(self): + # type: () -> str + """ + Format the given, non-None attributes for display. + """ + display_version = None + if self._given_py_version_info is not None: + display_version = '.'.join( + str(part) for part in self._given_py_version_info + ) + + key_values = [ + ('platform', self.platform), + ('version_info', display_version), + ('abi', self.abi), + ('implementation', self.implementation), + ] + return ' '.join( + '{}={!r}'.format(key, value) for key, value in key_values + if value is not None + ) def get_tags(self): + # type: () -> List[Pep425Tag] """ Return the supported tags to check wheel candidates against. """ diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py index 91adf17ddb9..c9d6894533f 100644 --- a/src/pip/_internal/utils/misc.py +++ b/src/pip/_internal/utils/misc.py @@ -28,6 +28,7 @@ from pip._vendor.six.moves.urllib import request as urllib_request from pip._vendor.six.moves.urllib.parse import unquote as urllib_unquote +from pip import __version__ from pip._internal.exceptions import CommandError, InstallationError from pip._internal.locations import ( running_under_virtualenv, site_packages, user_site, virtualenv_no_global, @@ -104,6 +105,18 @@ def cast(typ, val): logger.debug('lzma module is not available') +def get_pip_version(): + # type: () -> str + pip_pkg_dir = os.path.join(os.path.dirname(__file__), "..", "..") + pip_pkg_dir = os.path.abspath(pip_pkg_dir) + + return ( + 'pip {} from {} (python {})'.format( + __version__, pip_pkg_dir, sys.version[:3], + ) + ) + + def normalize_version_info(py_version_info): # type: (Optional[Tuple[int, ...]]) -> Optional[Tuple[int, int, int]] """ diff --git a/tests/functional/test_debug.py b/tests/functional/test_debug.py new file mode 100644 index 00000000000..3f5374722da --- /dev/null +++ b/tests/functional/test_debug.py @@ -0,0 +1,50 @@ +import pytest + +from pip._internal import pep425tags + + +@pytest.mark.parametrize( + 'args', + [ + [], + ['--verbose'], + ] +) +def test_debug(script, args): + """ + Check simple option cases. + """ + args = ['debug'] + args + result = script.pip(*args) + stdout = result.stdout + + assert 'sys.executable: ' in stdout + assert 'sys.platform: ' in stdout + assert 'sys.implementation:' in stdout + + tags = pep425tags.get_supported() + expected_tag_header = 'Compatible tags: {}'.format(len(tags)) + assert expected_tag_header in stdout + + show_verbose_note = '--verbose' not in args + assert ( + '...\n [First 10 tags shown. Pass --verbose to show all.]' in stdout + ) == show_verbose_note + + +@pytest.mark.parametrize( + 'args, expected', + [ + (['--python-version', '3.7'], "(target: version_info='3.7')"), + ] +) +def test_debug__target_options(script, args, expected): + """ + Check passing target-related options. + """ + args = ['debug'] + args + result = script.pip(*args) + stdout = result.stdout + + assert 'Compatible tags: ' in stdout + assert expected in stdout diff --git a/tests/unit/test_target_python.py b/tests/unit/test_target_python.py index 2230b12b7e2..0cca55e13a1 100644 --- a/tests/unit/test_target_python.py +++ b/tests/unit/test_target_python.py @@ -47,6 +47,29 @@ def test_init__py_version_info_none(self): assert target_python.py_version_info == CURRENT_PY_VERSION_INFO assert target_python.py_version == current_major_minor + @pytest.mark.parametrize('kwargs, expected', [ + ({}, ''), + (dict(py_version_info=(3, 6)), "version_info='3.6'"), + ( + dict(platform='darwin', py_version_info=(3, 6)), + "platform='darwin' version_info='3.6'", + ), + ( + dict( + platform='darwin', py_version_info=(3, 6), abi='cp36m', + implementation='cp' + ), + ( + "platform='darwin' version_info='3.6' abi='cp36m' " + "implementation='cp'" + ), + ), + ]) + def test_format_given(self, kwargs, expected): + target_python = TargetPython(**kwargs) + actual = target_python.format_given() + assert actual == expected + @pytest.mark.parametrize('py_version_info, expected_versions', [ ((), ['']), ((2, ), ['2']),