diff --git a/news/13018.bugfix.rst b/news/13018.bugfix.rst new file mode 100644 index 0000000000..0c0f038019 --- /dev/null +++ b/news/13018.bugfix.rst @@ -0,0 +1 @@ +Preserve primary output from display commands when ``--quiet`` is used. diff --git a/src/pip/_internal/commands/cache.py b/src/pip/_internal/commands/cache.py index 232ace8d40..19f5c2f343 100644 --- a/src/pip/_internal/commands/cache.py +++ b/src/pip/_internal/commands/cache.py @@ -8,7 +8,7 @@ from pip._internal.exceptions import CommandError, PipError from pip._internal.utils import filesystem from pip._internal.utils.logging import getLogger -from pip._internal.utils.misc import format_size +from pip._internal.utils.misc import format_size, write_output logger = getLogger(__name__) @@ -88,7 +88,7 @@ def get_cache_dir(self, options: Values, args: list[str]) -> None: if args: raise CommandError("Too many arguments") - logger.info(options.cache_dir) + write_output(options.cache_dir) def get_cache_info(self, options: Values, args: list[str]) -> None: if args: @@ -128,7 +128,7 @@ def get_cache_info(self, options: Values, args: list[str]) -> None: .strip() ) - logger.info(message) + write_output(message) def list_cache_items(self, options: Values, args: list[str]) -> None: if len(args) > 1: @@ -147,7 +147,7 @@ def list_cache_items(self, options: Values, args: list[str]) -> None: def format_for_human(self, files: list[str]) -> None: if not files: - logger.info("No locally built wheels cached.") + write_output("No locally built wheels cached.") return results = [] @@ -155,12 +155,12 @@ def format_for_human(self, files: list[str]) -> None: wheel = os.path.basename(filename) size = filesystem.format_file_size(filename) results.append(f" - {wheel} ({size})") - logger.info("Cache contents:\n") - logger.info("\n".join(sorted(results))) + write_output("Cache contents:\n") + write_output("\n".join(sorted(results))) def format_for_abspath(self, files: list[str]) -> None: if files: - logger.info("\n".join(sorted(files))) + write_output("\n".join(sorted(files))) def remove_cache_items(self, options: Values, args: list[str]) -> None: if len(args) > 1: diff --git a/src/pip/_internal/commands/debug.py b/src/pip/_internal/commands/debug.py index 7d5f9039c5..1caa9fcad3 100644 --- a/src/pip/_internal/commands/debug.py +++ b/src/pip/_internal/commands/debug.py @@ -19,17 +19,17 @@ from pip._internal.metadata import get_environment from pip._internal.utils.compat import get_locale_encoding, open_text_resource from pip._internal.utils.logging import indent_log -from pip._internal.utils.misc import get_pip_version +from pip._internal.utils.misc import get_pip_version, write_output logger = logging.getLogger(__name__) def show_value(name: str, value: Any) -> None: - logger.info("%s: %s", name, value) + write_output("%s: %s", name, value) def show_sys_implementation() -> None: - logger.info("sys.implementation:") + write_output("sys.implementation:") implementation_name = sys.implementation.name with indent_log(): show_value("name", implementation_name) @@ -91,11 +91,11 @@ def show_actual_vendor_versions(vendor_txt_versions: dict[str, str]) -> None: " (CONFLICT: vendor.txt suggests version should" f" be {expected_version})" ) - logger.info("%s==%s%s", module_name, actual_version, extra_message) + write_output("%s==%s%s", module_name, actual_version, extra_message) def show_vendor_versions() -> None: - logger.info("vendored library versions:") + write_output("vendored library versions:") vendor_txt_versions = create_vendor_txt_map() with indent_log(): @@ -115,7 +115,7 @@ def show_tags(options: Values) -> None: suffix = f" (target: {formatted_target})" msg = f"Compatible tags: {len(tags)}{suffix}" - logger.info(msg) + write_output(msg) if options.verbose < 1 and len(tags) > tag_limit: tags_limited = True @@ -125,11 +125,11 @@ def show_tags(options: Values) -> None: with indent_log(): for tag in tags: - logger.info(str(tag)) + write_output(str(tag)) if tags_limited: msg = f"...\n[First {tag_limit} tags shown. Pass --verbose to show all.]" - logger.info(msg) + write_output(msg) def ca_bundle_info(config: Configuration) -> str: diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 5c0a3a51de..9467f80437 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -141,6 +141,10 @@ def run(self, options: Values, args: list[str]) -> int: downloaded.append(req.name) if downloaded: - write_output("Successfully downloaded %s", " ".join(downloaded)) + write_output( + "Successfully downloaded %s", + " ".join(downloaded), + show_on_quiet=False, + ) return SUCCESS diff --git a/src/pip/_internal/commands/help.py b/src/pip/_internal/commands/help.py index 2ae658ff5e..353c6e990f 100644 --- a/src/pip/_internal/commands/help.py +++ b/src/pip/_internal/commands/help.py @@ -20,9 +20,13 @@ def run(self, options: Values, args: list[str]) -> int: ) try: - # 'pip help' with no args is handled by pip.__init__.parseopt() cmd_name = args[0] # the command we need help for except IndexError: + # 'pip help' with no args is handled by parse_command(), but + # command-level options like 'pip help --quiet' reach this point. + from pip._internal.cli.main_parser import create_main_parser + + create_main_parser().print_help() return SUCCESS if cmd_name not in commands_dict: diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index 47094abf7b..3b17525a7a 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -498,6 +498,7 @@ def run(self, options: Values, args: list[str]) -> int: write_output( "Would install %s", " ".join("-".join(item) for item in would_install_items), + show_on_quiet=False, ) return SUCCESS @@ -580,7 +581,7 @@ def run(self, options: Values, args: list[str]) -> int: resolver_variant=self.determine_resolver_variant(options), ) if summary := installed_packages_summary(installed, env): - write_output(summary) + write_output(summary, show_on_quiet=False) except OSError as error: show_traceback = self.verbosity >= 1 diff --git a/src/pip/_internal/commands/search.py b/src/pip/_internal/commands/search.py index b8dbc27d3a..a65f340be6 100644 --- a/src/pip/_internal/commands/search.py +++ b/src/pip/_internal/commands/search.py @@ -117,17 +117,18 @@ def print_dist_installation_info(latest: str, dist: BaseDistribution | None) -> if dist is not None: with indent_log(): if dist.version == latest: - write_output("INSTALLED: %s (latest)", dist.version) + write_output("INSTALLED: %s (latest)", dist.version, show_on_quiet=True) else: - write_output("INSTALLED: %s", dist.version) + write_output("INSTALLED: %s", dist.version, show_on_quiet=True) if parse_version(latest).pre: write_output( "LATEST: %s (pre-release; install" " with `pip install --pre`)", latest, + show_on_quiet=True, ) else: - write_output("LATEST: %s", latest) + write_output("LATEST: %s", latest, show_on_quiet=True) def get_installed_distribution(name: str) -> BaseDistribution | None: diff --git a/src/pip/_internal/utils/logging.py b/src/pip/_internal/utils/logging.py index e067703580..4d9e753e81 100644 --- a/src/pip/_internal/utils/logging.py +++ b/src/pip/_internal/utils/logging.py @@ -250,6 +250,42 @@ def handleError(self, record: logging.LogRecord) -> None: return super().handleError(record) +def _format_output_message(msg: Any, *args: Any) -> str: + # Match logging's %-style argument handling; write_output passes the + # same message and args to logger.info when the console can show INFO. + if args: + return str(msg) % args + return str(msg) + + +def should_directly_write_output(logger: logging.Logger) -> bool: + if not logger.isEnabledFor(logging.INFO): + return True + + has_console_handler = False + for handler in logging.getLogger().handlers: + if not isinstance(handler, RichPipStreamHandler): + continue + has_console_handler = True + if handler.level <= logging.INFO: + return False + + return has_console_handler + + +def write_output_direct(msg: Any, *args: Any) -> None: + text = _format_output_message(msg, *args) + indentation = get_indentation() + if indentation: + prefix = " " * indentation + text = "".join(prefix + line for line in text.splitlines(True)) + + try: + get_console().print(text, overflow="ignore", crop=False, highlight=False) + except BrokenPipeError as exc: + raise BrokenStdoutLoggingError() from exc + + class BetterRotatingFileHandler(logging.handlers.RotatingFileHandler): def _open(self) -> TextIOWrapper: ensure_dir(os.path.dirname(self.baseFilename)) diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py index ca304e9e28..dceb916950 100644 --- a/src/pip/_internal/utils/misc.py +++ b/src/pip/_internal/utils/misc.py @@ -381,7 +381,17 @@ def is_local(path: str) -> bool: return path.startswith(normalize_path(sys.prefix)) -def write_output(msg: Any, *args: Any) -> None: +def write_output(msg: Any, *args: Any, show_on_quiet: bool = True) -> None: + if show_on_quiet: + from pip._internal.utils.logging import ( + should_directly_write_output, + write_output_direct, + ) + + if should_directly_write_output(logger): + write_output_direct(msg, *args) + return + logger.info(msg, *args) diff --git a/tests/functional/test_cache.py b/tests/functional/test_cache.py index 2388c362a5..e289a2e3fd 100644 --- a/tests/functional/test_cache.py +++ b/tests/functional/test_cache.py @@ -184,6 +184,13 @@ def test_cache_dir(script: PipTestEnvironment, cache_dir: str) -> None: assert os.path.normcase(cache_dir) == result.stdout.strip() +def test_cache_dir_quiet(script: PipTestEnvironment, cache_dir: str) -> None: + """Test that quiet mode does not suppress the cache dir output.""" + result = script.pip("cache", "dir", "--quiet") + + assert os.path.normcase(cache_dir) == result.stdout.strip() + + def test_cache_dir_too_many_args(script: PipTestEnvironment, cache_dir: str) -> None: result = script.pip("cache", "dir", "aaa", expect_error=True) diff --git a/tests/functional/test_check.py b/tests/functional/test_check.py index acf99bc13c..3a733d17fb 100644 --- a/tests/functional/test_check.py +++ b/tests/functional/test_check.py @@ -25,6 +25,15 @@ def test_basic_check_clean(script: PipTestEnvironment) -> None: assert result.returncode == 0 +def test_basic_check_clean_quiet(script: PipTestEnvironment) -> None: + """Quiet mode should not suppress check's dependency report.""" + result = script.pip("check", "--quiet") + + expected_lines = ("No broken requirements found.",) + assert matches_expected_lines(result.stdout, expected_lines) + assert result.returncode == 0 + + def test_basic_check_missing_dependency(script: PipTestEnvironment) -> None: # Setup a small project pkga_path = create_test_package_with_setup( diff --git a/tests/functional/test_configuration.py b/tests/functional/test_configuration.py index f443e4c70f..f7233aedaf 100644 --- a/tests/functional/test_configuration.py +++ b/tests/functional/test_configuration.py @@ -54,6 +54,9 @@ def test_basic_modification_pipeline(self, script: PipTestEnvironment) -> None: result = script.pip("config", "get", "test.blah") assert result.stdout.strip() == "1" + result = script.pip("config", "get", "test.blah", "--quiet") + assert result.stdout.strip() == "1" + script.pip("config", "unset", "test.blah") script.pip("config", "get", "test.blah", expect_error=True) diff --git a/tests/functional/test_debug.py b/tests/functional/test_debug.py index 331a2e6575..480e5777c3 100644 --- a/tests/functional/test_debug.py +++ b/tests/functional/test_debug.py @@ -39,6 +39,16 @@ def test_debug(script: PipTestEnvironment, expected_text: str) -> None: assert expected_text in stdout +def test_debug_quiet(script: PipTestEnvironment) -> None: + """ + Check that quiet mode does not suppress debug's primary output. + """ + result = script.pip("debug", "--quiet", allow_stderr_warning=True) + + assert "pip version: " in result.stdout + assert "Compatible tags: " in result.stdout + + def test_debug__library_versions(script: PipTestEnvironment) -> None: """ Check the library versions normal output. diff --git a/tests/functional/test_hash.py b/tests/functional/test_hash.py index cf993b6feb..7c0d18d2c1 100644 --- a/tests/functional/test_hash.py +++ b/tests/functional/test_hash.py @@ -15,6 +15,16 @@ def test_basic_hash(script: PipTestEnvironment, tmpdir: Path) -> None: assert expected in str(result) +def test_basic_hash_quiet(script: PipTestEnvironment, tmpdir: Path) -> None: + """Quiet mode should not suppress the generated hash.""" + expected = ( + "--hash=sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425" + "e73043362938b9824" + ) + result = script.pip("hash", "--quiet", _hello_file(tmpdir)) + assert expected in str(result) + + def test_good_algo_option(script: PipTestEnvironment, tmpdir: Path) -> None: """Make sure the -a option works.""" expected = ( diff --git a/tests/functional/test_help.py b/tests/functional/test_help.py index 251a4bf341..bbcc50e7f4 100644 --- a/tests/functional/test_help.py +++ b/tests/functional/test_help.py @@ -66,6 +66,16 @@ def test_help_command_should_exit_status_ok_when_no_cmd_is_specified( assert result.returncode == SUCCESS +def test_help_command_quiet_shows_general_help(script: PipTestEnvironment) -> None: + """ + Test that quiet mode does not suppress the no-argument help command. + """ + result = script.pip("help", "--quiet") + assert result.returncode == SUCCESS + assert "Usage:" in result.stdout + assert "Commands:" in result.stdout + + def test_help_command_should_exit_status_error_when_cmd_does_not_exist( script: PipTestEnvironment, ) -> None: diff --git a/tests/functional/test_index.py b/tests/functional/test_index.py index 57cfe7af05..9d9ded7189 100644 --- a/tests/functional/test_index.py +++ b/tests/functional/test_index.py @@ -142,6 +142,26 @@ def test_index_versions_all_releases_for_package(script: PipTestEnvironment) -> assert "2.0a1" in result.stdout +def test_index_versions_quiet_shows_versions(script: PipTestEnvironment) -> None: + """Test that quiet mode does not suppress index versions output.""" + wheelhouse_path = script.scratch_path / "wheelhouse" + wheelhouse_path.mkdir() + make_wheel("simple", "1.0").save_to_dir(wheelhouse_path) + + result = script.pip( + "index", + "versions", + "--quiet", + "--no-index", + "--find-links", + wheelhouse_path, + "simple", + ) + + assert "simple (1.0)" in result.stdout + assert "Available versions: 1.0" in result.stdout + + def test_index_versions_only_final_for_package(script: PipTestEnvironment) -> None: """Test that --only-final filters prereleases for specific package.""" # Create fake local package index with prerelease diff --git a/tests/functional/test_list.py b/tests/functional/test_list.py index c308c3b860..83dc792362 100644 --- a/tests/functional/test_list.py +++ b/tests/functional/test_list.py @@ -45,6 +45,15 @@ def test_basic_list(simple_script: PipTestEnvironment) -> None: assert "simple2 3.0" in result.stdout, str(result) +def test_basic_list_quiet(simple_script: PipTestEnvironment) -> None: + """ + Test that quiet mode does not suppress the list command's package output. + """ + result = simple_script.pip("list", "--quiet") + assert "simple 1.0" in result.stdout, str(result) + assert "simple2 3.0" in result.stdout, str(result) + + def test_verbose_flag(simple_script: PipTestEnvironment) -> None: """ Test the list command with the '-v' option diff --git a/tests/functional/test_show.py b/tests/functional/test_show.py index a658b829f3..ded0aee3a3 100644 --- a/tests/functional/test_show.py +++ b/tests/functional/test_show.py @@ -32,6 +32,17 @@ def test_basic_show(script: PipTestEnvironment) -> None: assert "Requires: " in lines +def test_basic_show_quiet(script: PipTestEnvironment) -> None: + """ + Test that quiet mode does not suppress the show command's package output. + """ + result = script.pip("show", "--quiet", "pip") + lines = result.stdout.splitlines() + assert "Name: pip" in lines + assert f"Version: {__version__}" in lines + assert any(line.startswith("Location: ") for line in lines) + + def test_show_without_files_does_not_read_installed_files( monkeypatch: pytest.MonkeyPatch, ) -> None: