Repository: pipxproject/pipx (python)

Issue:
pipx list --json can print non-json to stdout
When nothing is installed, `pipx list --json` prints `nothing has been installed with pipx 😴` to stdout, and that's not valid json.

_Originally posted by @liskin in https://github.com/pipxproject/pipx/issues/660#issuecomment-828473735_

In addition, any warnings about the venvs are also printed to stdout, which is also non-valid json!

Pull request:
Change `pipx list` warnings and info to output on stderr
<!-- add an 'x' in the brackets below -->
* [ ] I have added an entry to `docs/changelog.md`

## Summary of changes
Closes #678 

I changed all informational messages from `pipx list` to output to stderr.  Actual warning messages use `logger.warning()`.

This needs to be fixed for `pipx list --json` to work properly and output valid json.

Let me know if there's any reason that a user might want these info messages on stdout, perhaps for a normal non-json `pipx list`.  I personally don't believe so, but I might be wrong. 🙂

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```
# command(s) to exercise these changes
```


Patch:
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index 46a778f..e6d0948 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -168,23 +168,26 @@ def venv_health_check(
     if not python_path.is_file():
         return (
             VenvProblems(invalid_interpreter=True),
-            f"   package {red(bold(venv_dir.name))} has invalid interpreter {str(python_path)}",
+            f"   package {red(bold(venv_dir.name))} has invalid "
+            f"interpreter {str(python_path)}\r{hazard}",
         )
     if not venv.package_metadata:
         return (
             VenvProblems(missing_metadata=True),
-            f"   package {red(bold(venv_dir.name))} has missing internal pipx metadata.",
+            f"   package {red(bold(venv_dir.name))} has missing "
+            f"internal pipx metadata.\r{hazard}",
         )
     if venv_dir.name != canonicalize_name(venv_dir.name):
         return (
             VenvProblems(bad_venv_name=True),
-            f"   package {red(bold(venv_dir.name))} needs its internal data updated.",
+            f"   package {red(bold(venv_dir.name))} needs its "
+            f"internal data updated.\r{hazard}",
         )
     if venv.package_metadata[package_name].package_version == "":
         return (
             VenvProblems(not_installed=True),
             f"   package {red(bold(package_name))} {red('is not installed')} "
-            f"in the venv {venv_dir.name}",
+            f"in the venv {venv_dir.name}\r{hazard}",
         )
     return (VenvProblems(), "")
 
diff --git a/src/pipx/commands/list_packages.py b/src/pipx/commands/list_packages.py
index 1b72631..6a79aaf 100644
--- a/src/pipx/commands/list_packages.py
+++ b/src/pipx/commands/list_packages.py
@@ -1,4 +1,5 @@
 import json
+import logging
 import sys
 from pathlib import Path
 from typing import Any, Collection, Dict, Tuple
@@ -11,6 +12,8 @@ from pipx.emojis import sleep
 from pipx.pipx_metadata_file import JsonEncoderHandlesPath, PipxMetadata
 from pipx.venv import Venv, VenvContainer
 
+logger = logging.getLogger(__name__)
+
 PIPX_SPEC_VERSION = "0.1"
 
 
@@ -35,7 +38,10 @@ def list_text(
         package_summary, venv_problems = get_venv_summary(
             venv_dir, include_injected=include_injected
         )
-        print(package_summary)
+        if venv_problems.any_():
+            logger.warning(package_summary)
+        else:
+            print(package_summary)
         all_venv_problems.or_(venv_problems)
 
     return all_venv_problems
@@ -64,7 +70,7 @@ def list_json(venv_dirs: Collection[Path]) -> VenvProblems:
         json.dumps(spec_metadata, indent=4, sort_keys=True, cls=JsonEncoderHandlesPath)
     )
     for warning_message in warning_messages:
-        print(warning_message, file=sys.stderr)
+        logger.warning(warning_message)
 
     return all_venv_problems
 
@@ -75,7 +81,7 @@ def list_packages(
     """Returns pipx exit code."""
     venv_dirs: Collection[Path] = sorted(venv_container.iter_venv_dirs())
     if not venv_dirs:
-        print(f"nothing has been installed with pipx {sleep}")
+        print(f"nothing has been installed with pipx {sleep}", file=sys.stderr)
         return EXIT_CODE_OK
 
     venv_container.verify_shared_libs()
@@ -86,30 +92,30 @@ def list_packages(
         all_venv_problems = list_text(venv_dirs, include_injected, str(venv_container))
 
     if all_venv_problems.bad_venv_name:
-        print(
+        logger.warning(
             "\nOne or more packages contain out-of-date internal data installed from a\n"
             "previous pipx version and need to be updated.\n"
             "    To fix, execute: pipx reinstall-all"
         )
     if all_venv_problems.invalid_interpreter:
-        print(
+        logger.warning(
             "\nOne or more packages have a missing python interpreter.\n"
             "    To fix, execute: pipx reinstall-all"
         )
     if all_venv_problems.missing_metadata:
-        print(
+        logger.warning(
             "\nOne or more packages have a missing internal pipx metadata.\n"
             "   They were likely installed using a pipx version before 0.15.0.0.\n"
             "   Please uninstall and install these package(s) to fix."
         )
     if all_venv_problems.not_installed:
-        print(
+        logger.warning(
             "\nOne or more packages are not installed properly.\n"
             "   Please uninstall and install these package(s) to fix."
         )
 
     if all_venv_problems.any_():
-        print()
+        print("", file=sys.stderr)
         return EXIT_CODE_LIST_PROBLEM
 
     return EXIT_CODE_OK


Test patch:
diff --git a/tests/test_list.py b/tests/test_list.py
index 97772c9..221d6f5 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -19,7 +19,7 @@ from pipx.pipx_metadata_file import PackageInfo, _json_decoder_object_hook
 def test_cli(pipx_temp_env, monkeypatch, capsys):
     assert not run_pipx_cli(["list"])
     captured = capsys.readouterr()
-    assert "nothing has been installed with pipx" in captured.out
+    assert "nothing has been installed with pipx" in captured.err
 
 
 def test_missing_interpreter(pipx_temp_env, monkeypatch, capsys):
@@ -27,13 +27,13 @@ def test_missing_interpreter(pipx_temp_env, monkeypatch, capsys):
 
     assert not run_pipx_cli(["list"])
     captured = capsys.readouterr()
-    assert "package pycowsay has invalid interpreter" not in captured.out
+    assert "package pycowsay has invalid interpreter" not in captured.err
 
     remove_venv_interpreter("pycowsay")
 
     assert run_pipx_cli(["list"])
     captured = capsys.readouterr()
-    assert "package pycowsay has invalid interpreter" in captured.out
+    assert "package pycowsay has invalid interpreter" in captured.err
 
 
 def test_list_suffix(pipx_temp_env, monkeypatch, capsys):
@@ -53,7 +53,7 @@ def test_list_legacy_venv(pipx_temp_env, monkeypatch, capsys, metadata_version):
     if metadata_version is None:
         assert run_pipx_cli(["list"])
         captured = capsys.readouterr()
-        assert "package pycowsay has missing internal pipx metadata" in captured.out
+        assert "package pycowsay has missing internal pipx metadata" in captured.err
     else:
         assert not run_pipx_cli(["list"])
         captured = capsys.readouterr()