diff --git a/news/FAC843E9-BB25-4CA9-9F01-0398E5B796ED.trivial b/news/FAC843E9-BB25-4CA9-9F01-0398E5B796ED.trivial new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pip/_internal/commands/show.py b/src/pip/_internal/commands/show.py index 24e855a80d8..a99551ce8a5 100644 --- a/src/pip/_internal/commands/show.py +++ b/src/pip/_internal/commands/show.py @@ -1,3 +1,4 @@ +import json import logging import os from email.parser import FeedParser @@ -33,7 +34,14 @@ def add_options(self): action='store_true', default=False, help='Show the full list of installed files for each package.') - + self.cmd_opts.add_option( + '--format', + action='store', + dest='list_format', + default="header", + choices=('header', 'json'), + help="Select the output format among: header (default) or json", + ) self.parser.insert_option_group(0, self.cmd_opts) def run(self, options, args): @@ -41,13 +49,26 @@ def run(self, options, args): if not args: logger.warning('ERROR: Please provide a package name or names.') return ERROR + query = args + # Get list of package infos and print them results = search_packages_info(query) - if not print_results( - results, list_files=options.files, verbose=options.verbose): - return ERROR - return SUCCESS + return_status = False + + if options.list_format == 'header': + + return_status = print_results_default(results, + list_files=options.files, + verbose=options.verbose) + + elif options.list_format == 'json': + + return_status = print_results_json(results, + list_files=options.files, + verbose=options.verbose) + + return SUCCESS if return_status else ERROR def search_packages_info(query): @@ -140,10 +161,10 @@ def get_requiring_packages(package_name): yield package -def print_results(distributions, list_files=False, verbose=False): +def print_results_default(distributions, list_files=False, verbose=False): # type: (Iterator[Dict[str, str]], bool, bool) -> bool """ - Print the information from installed distributions found. + Print the information from installed distributions found in default format. """ results_printed = False for i, dist in enumerate(distributions): @@ -179,3 +200,52 @@ def print_results(distributions, list_files=False, verbose=False): if "files" not in dist: write_output("Cannot locate installed-files.txt") return results_printed + + +def print_results_json(distributions, list_files=False, verbose=False): + # type: (Iterator[Dict[str, Any]], bool, bool) -> bool + """ + Build a dictionary with information from installed distributions + found in JSON format. + """ + + results_printed = False + pkg_infos_list = [] + + for dist in distributions: + results_printed = True + pkg_info = {} + + pkg_info["name"] = dist.get('name', '') + pkg_info["version"] = dist.get('version', '') + + pkg_info["summary"] = dist.get('summary', '') + pkg_info["home-page"] = dist.get('home-page', '') + pkg_info["author"] = dist.get('author', '') + pkg_info["author-email"] = dist.get('author-email', '') + pkg_info["license"] = dist.get('license', '') + pkg_info["location"] = dist.get('location', '') + pkg_info["requires"] = dist.get('requires', []) + pkg_info["required-by"] = dist.get('required_by', []) + + if verbose: + + pkg_info["metadata-version"] = dist.get('metadata-version', '') + pkg_info["installer"] = dist.get('installer', '') + pkg_info["classifiers"] = dist.get('classifiers', []) + pkg_info["entry-points"] = \ + [entry.strip() for entry in dist.get('entry_points', [])]\ + if 'entry_points' in dist else [] + + if list_files: + if "files" not in dist: + pkg_info["files"] = None + else: + pkg_info['files'] = [line.strip() + for line in dist.get('files', [])] + + pkg_infos_list.append(pkg_info) + + write_output(json.dumps(pkg_infos_list, ensure_ascii=False)) + + return results_printed diff --git a/tests/functional/test_show.py b/tests/functional/test_show.py index 7047aa63aa8..397131da178 100644 --- a/tests/functional/test_show.py +++ b/tests/functional/test_show.py @@ -1,3 +1,4 @@ +import json import os import re @@ -21,6 +22,18 @@ def test_basic_show(script): assert 'Requires: ' in lines +def test_basic_show_json(script): + """ + Test end to end test for show command. + """ + result = script.pip('show', 'pip', '--format=json') + data = json.loads(result.stdout)[0] + assert len(data) == 10 + assert data['name'] == 'pip' + assert data['version'] == '{}'.format(__version__) + assert {'location', 'requires'} <= set(data) + + def test_show_with_files_not_found(script, data): """ Test for show command with installed files listing enabled and @@ -39,6 +52,21 @@ def test_show_with_files_not_found(script, data): assert 'Cannot locate installed-files.txt' in lines +def test_show_with_files_not_found_json(script, data): + """ + Test for show command with installed files listing enabled and + installed-files.txt not found. + """ + editable = data.packages.joinpath('SetupPyUTF8') + script.pip('install', '-e', editable) + result = script.pip('show', '-f', 'SetupPyUTF8', '--format=json') + data = json.loads(result.stdout)[0] + assert data['name'] == 'SetupPyUTF8' + assert data['version'] == '0.0.0' + assert {'location', 'requires'} <= set(data) + assert not data['files'] + + def test_show_with_files_from_wheel(script, data): """ Test that a wheel's files can be listed @@ -52,6 +80,18 @@ def test_show_with_files_from_wheel(script, data): assert re.search(r"Files:\n( .+\n)+", result.stdout) +def test_show_with_files_from_wheel_json(script, data): + """ + Test that a wheel's files can be listed + """ + wheel_file = data.packages.joinpath('simple.dist-0.1-py2.py3-none-any.whl') + script.pip('install', '--no-index', wheel_file) + result = script.pip('show', '-f', 'simple.dist', '--format=json') + data = json.loads(result.stdout)[0] + assert data['name'] == 'simple.dist' + assert data['files'] + + @pytest.mark.network def test_show_with_all_files(script): """ @@ -64,6 +104,17 @@ def test_show_with_all_files(script): assert re.search(r"Files:\n( .+\n)+", result.stdout) +@pytest.mark.network +def test_show_with_all_files_json(script): + """ + Test listing all files in the show command. + """ + script.pip('install', 'initools==0.2') + result = script.pip('show', '--files', 'initools', '--format=json') + data = json.loads(result.stdout)[0] + assert data['files'] + + def test_missing_argument(script): """ Test show command with no arguments. @@ -108,6 +159,21 @@ def test_report_mixed_not_found(script): assert 'Name: pip' in lines +def test_report_mixed_not_found_json(script): + """ + Test passing a mixture of found and not-found names. + """ + # We test passing non-canonicalized names. + result = script.pip( + 'show', 'Abcd3', 'A-B-C', 'pip', '--format=json', + allow_stderr_warning=True + ) + assert 'WARNING: Package(s) not found: A-B-C, Abcd3' in result.stderr + print(result.stdout) + data = json.loads(result.stdout)[0] + assert data['name'] == 'pip' + + def test_search_any_case(): """ Search for a package in any case. @@ -138,6 +204,17 @@ def test_show_verbose_with_classifiers(script): assert "Intended Audience :: Developers" in result.stdout +def test_show_verbose_with_classifiers_json(script): + """ + Test that classifiers can be listed + """ + result = script.pip('show', 'pip', '--verbose', '--format=json') + data = json.loads(result.stdout)[0] + assert data['name'] == 'pip' + assert data['classifiers'] + assert "Intended Audience :: Developers" in data["classifiers"] + + def test_show_verbose_installer(script, data): """ Test that the installer is shown (this currently needs a wheel install) @@ -150,6 +227,18 @@ def test_show_verbose_installer(script, data): assert 'Installer: pip' in lines +def test_show_verbose_installer_json(script, data): + """ + Test that the installer is shown (this currently needs a wheel install) + """ + wheel_file = data.packages.joinpath('simple.dist-0.1-py2.py3-none-any.whl') + script.pip('install', '--no-index', wheel_file) + result = script.pip('show', '--verbose', 'simple.dist', '--format=json') + data = json.loads(result.stdout)[0] + assert data['name'] == 'simple.dist' + assert data['installer'] == 'pip' + + def test_show_verbose(script): """ Test end to end test for verbose show command. @@ -162,6 +251,17 @@ def test_show_verbose(script): assert 'Classifiers:' in lines +def test_show_verbose_json(script): + """ + Test end to end test for verbose show command. + """ + result = script.pip('show', '--verbose', 'pip', '--format=json') + data = json.loads(result.stdout)[0] + + assert {'metadata-version', 'installer', + 'entry-points', 'classifiers'} <= set(data) + + def test_all_fields(script): """ Test that all the fields are present @@ -175,6 +275,18 @@ def test_all_fields(script): assert actual == expected +def test_all_fields_json(script): + """ + Test that all the fields are present + """ + result = script.pip('show', 'pip', '--format=json') + data = json.loads(result.stdout)[0] + expected = {'name', 'version', 'summary', 'home-page', 'author', + 'author-email', 'license', 'location', 'requires', + 'required-by'} + assert set(data) == expected + + def test_pip_show_is_short(script): """ Test that pip show stays short @@ -184,6 +296,15 @@ def test_pip_show_is_short(script): assert len(lines) <= 10 +def test_pip_show_is_short_json(script): + """ + Test that pip show stays short + """ + result = script.pip('show', 'pip', '--format=json') + data = json.loads(result.stdout)[0] + assert len(data) <= 10 + + def test_pip_show_divider(script, data): """ Expect a divider between packages @@ -222,6 +343,22 @@ def test_show_required_by_packages_basic(script, data): assert 'Required-by: requires-simple' in lines +def test_show_required_by_packages_basic_json(script, data): + """ + Test that installed packages that depend on this package are shown + """ + editable_path = os.path.join(data.src, 'requires_simple') + script.pip( + 'install', '--no-index', '-f', data.find_links, editable_path + ) + + result = script.pip('show', 'simple', '--format=json') + data = json.loads(result.stdout)[0] + + assert data['name'] == 'simple' + assert data['required-by'] == ['requires-simple'] + + def test_show_required_by_packages_capitalized(script, data): """ Test that the installed packages which depend on a package are shown @@ -239,6 +376,23 @@ def test_show_required_by_packages_capitalized(script, data): assert 'Required-by: Requires-Capitalized' in lines +def test_show_required_by_packages_capitalized_json(script, data): + """ + Test that the installed packages which depend on a package are shown + where the package has a capital letter + """ + editable_path = os.path.join(data.src, 'requires_capitalized') + script.pip( + 'install', '--no-index', '-f', data.find_links, editable_path + ) + + result = script.pip('show', 'simple', '--format=json') + data = json.loads(result.stdout)[0] + + assert data['name'] == 'simple' + assert data['required-by'] == ['Requires-Capitalized'] + + def test_show_required_by_packages_requiring_capitalized(script, data): """ Test that the installed packages which depend on a package are shown @@ -292,7 +446,6 @@ def test_show_include_work_dir_pkg(script): expect_stderr=True, cwd=pkg_path) script.environ.update({'PYTHONPATH': pkg_path}) - # Show should include package simple when run from package directory, # when package directory is in PYTHONPATH result = script.pip('show', 'simple', cwd=pkg_path)