Repository: pypa/pipx (python)

Issue:
pipx run --quiet mode
**How would this feature be useful?**
`pipx run` is currently difficult to use for tools with exact stdout/stderr because it prints various messages related to pipx operation. This is relevant when attempting to parse stdout/stderr from tools run via pipx

For example I would like use `pipx run 'conan<2'` and `pipx run conan` in parallel without any additional mesages.

**Describe the solution you'd like**
Add a --quiet option to pipx run which guarantees that no output to stdout and stderr except from the tool being executed.

Perhaps a message from pipx could still be printed if loading the package fails.

**Describe alternatives you've considered**
Perhaps something with `pipx install`? I'd rather use `pipx run` as a standalone-command.

Pull request description:
Add consistent quiet and verbose modes for the `pipx` commands
<!-- add an 'x' in the brackets below -->

- [x] I have added an entry to `docs/changelog.md`

## Summary of changes

Same as #1044, with merge conflicts fixed. Closes #946.

## 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/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8876126..9071db9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,13 +11,13 @@ repos:
   hooks:
     - id: pyproject-fmt
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.1.8
+  rev: v0.1.9
   hooks:
   - id: ruff-format
   - id: ruff
     args: [ "--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v1.7.1
+  rev: v1.8.0
   hooks:
   - id: mypy
     args: ['--warn-unused-ignores', '--strict-equality','--no-implicit-optional', '--check-untyped-defs']
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2f546b..5003784 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## dev
 
+- Add `--quiet` and `--verbose` options for the `pipx` subcommands
 - [docs] Add Scoop installation instructions
 - Add ability to install multiple packages at once
 
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 9b0fde4..228fa63 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -327,16 +327,16 @@ def add_include_dependencies(parser: argparse.ArgumentParser) -> None:
     parser.add_argument("--include-deps", help="Include apps of dependent packages", action="store_true")
 
 
-def _add_install(subparsers: argparse._SubParsersAction) -> None:
+def _add_install(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "install",
         help="Install a package",
         formatter_class=LineWrapRawTextHelpFormatter,
         description=INSTALL_DESCRIPTION,
+        parents=[shared_parser],
     )
     p.add_argument("package_spec", help="package name(s) or pip installation spec(s)", nargs="*")
     add_include_dependencies(p)
-    p.add_argument("--verbose", action="store_true")
     p.add_argument(
         "--force",
         "-f",
@@ -368,11 +368,12 @@ def _add_install(subparsers: argparse._SubParsersAction) -> None:
     add_pip_venv_args(p)
 
 
-def _add_inject(subparsers, venv_completer: VenvCompleter) -> None:
+def _add_inject(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "inject",
         help="Install packages into an existing Virtual Environment",
         description="Installs packages to an existing pipx-managed virtual environment.",
+        parents=[shared_parser],
     )
     p.add_argument(
         "package",
@@ -400,7 +401,6 @@ def _add_inject(subparsers, venv_completer: VenvCompleter) -> None:
         action="store_true",
         help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR",
     )
-    p.add_argument("--verbose", action="store_true")
     p.add_argument(
         "--with-suffix",
         action="store_true",
@@ -408,11 +408,12 @@ def _add_inject(subparsers, venv_completer: VenvCompleter) -> None:
     )
 
 
-def _add_uninject(subparsers, venv_completer: VenvCompleter):
+def _add_uninject(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser):
     p = subparsers.add_parser(
         "uninject",
         help="Uninstall injected packages from an existing Virtual Environment",
         description="Uninstalls injected packages from an existing pipx-managed virtual environment.",
+        parents=[shared_parser],
     )
     p.add_argument(
         "package",
@@ -428,14 +429,14 @@ def _add_uninject(subparsers, venv_completer: VenvCompleter):
         action="store_true",
         help="Only uninstall the main injected package but leave its dependencies installed.",
     )
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_upgrade(subparsers, venv_completer: VenvCompleter) -> None:
+def _add_upgrade(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "upgrade",
         help="Upgrade a package",
         description="Upgrade a package in a pipx-managed Virtual Environment by running 'pip install --upgrade PACKAGE'",
+        parents=[shared_parser],
     )
     p.add_argument("package").completer = venv_completer
     p.add_argument(
@@ -450,14 +451,14 @@ def _add_upgrade(subparsers, venv_completer: VenvCompleter) -> None:
         help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR",
     )
     add_pip_venv_args(p)
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_upgrade_all(subparsers: argparse._SubParsersAction) -> None:
+def _add_upgrade_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "upgrade-all",
         help="Upgrade all packages. Runs `pip install -U <pkgname>` for each package.",
         description="Upgrades all packages within their virtual environments by running 'pip install --upgrade PACKAGE'",
+        parents=[shared_parser],
     )
     p.add_argument(
         "--include-injected",
@@ -471,29 +472,28 @@ def _add_upgrade_all(subparsers: argparse._SubParsersAction) -> None:
         action="store_true",
         help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR",
     )
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_uninstall(subparsers, venv_completer: VenvCompleter) -> None:
+def _add_uninstall(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "uninstall",
         help="Uninstall a package",
         description="Uninstalls a pipx-managed Virtual Environment by deleting it and any files that point to its apps.",
+        parents=[shared_parser],
     )
     p.add_argument("package").completer = venv_completer
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_uninstall_all(subparsers: argparse._SubParsersAction) -> None:
-    p = subparsers.add_parser(
+def _add_uninstall_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
+    subparsers.add_parser(
         "uninstall-all",
         help="Uninstall all packages",
         description="Uninstall all pipx-managed packages",
+        parents=[shared_parser],
     )
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_reinstall(subparsers, venv_completer: VenvCompleter) -> None:
+def _add_reinstall(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "reinstall",
         formatter_class=LineWrapRawTextHelpFormatter,
@@ -507,6 +507,7 @@ def _add_reinstall(subparsers, venv_completer: VenvCompleter) -> None:
 
             """
         ),
+        parents=[shared_parser],
     )
     p.add_argument("package").completer = venv_completer
     p.add_argument(
@@ -518,10 +519,9 @@ def _add_reinstall(subparsers, venv_completer: VenvCompleter) -> None:
             f"Requires Python {MINIMUM_PYTHON_VERSION} or above."
         ),
     )
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_reinstall_all(subparsers: argparse._SubParsersAction) -> None:
+def _add_reinstall_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "reinstall-all",
         formatter_class=LineWrapRawTextHelpFormatter,
@@ -537,6 +537,7 @@ def _add_reinstall_all(subparsers: argparse._SubParsersAction) -> None:
 
             """
         ),
+        parents=[shared_parser],
     )
     p.add_argument(
         "--python",
@@ -548,14 +549,14 @@ def _add_reinstall_all(subparsers: argparse._SubParsersAction) -> None:
         ),
     )
     p.add_argument("--skip", nargs="+", default=[], help="skip these packages")
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_list(subparsers: argparse._SubParsersAction) -> None:
+def _add_list(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "list",
         help="List installed packages",
         description="List packages and apps installed with pipx",
+        parents=[shared_parser],
     )
     p.add_argument(
         "--include-injected",
@@ -565,10 +566,9 @@ def _add_list(subparsers: argparse._SubParsersAction) -> None:
     g = p.add_mutually_exclusive_group()
     g.add_argument("--json", action="store_true", help="Output rich data in json format.")
     g.add_argument("--short", action="store_true", help="List packages only.")
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_run(subparsers: argparse._SubParsersAction) -> None:
+def _add_run(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "run",
         formatter_class=LineWrapRawTextHelpFormatter,
@@ -591,6 +591,7 @@ def _add_run(subparsers: argparse._SubParsersAction) -> None:
             removed in the future. See https://github.com/cs01/pythonloc.
             """
         ),
+        parents=[shared_parser],
     )
     p.add_argument(
         "--no-cache",
@@ -611,7 +612,6 @@ def _add_run(subparsers: argparse._SubParsersAction) -> None:
         help="Require app to be run from local __pypackages__ directory",
     )
     p.add_argument("--spec", help=SPEC_HELP)
-    p.add_argument("--verbose", action="store_true")
     p.add_argument(
         "--python",
         default=DEFAULT_PYTHON,
@@ -630,11 +630,12 @@ def _add_run(subparsers: argparse._SubParsersAction) -> None:
     p.usage = re.sub(r"\.\.\.", "app ...", p.usage)
 
 
-def _add_runpip(subparsers, venv_completer: VenvCompleter) -> None:
+def _add_runpip(subparsers, venv_completer: VenvCompleter, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "runpip",
         help="Run pip in an existing pipx-managed Virtual Environment",
         description="Run pip in an existing pipx-managed Virtual Environment",
+        parents=[shared_parser],
     )
     p.add_argument(
         "package",
@@ -646,10 +647,9 @@ def _add_runpip(subparsers, venv_completer: VenvCompleter) -> None:
         default=[],
         help="Arguments to forward to pip command",
     )
-    p.add_argument("--verbose", action="store_true")
 
 
-def _add_ensurepath(subparsers: argparse._SubParsersAction) -> None:
+def _add_ensurepath(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "ensurepath",
         help=("Ensure directories necessary for pipx operation are in your " "PATH environment variable."),
@@ -660,6 +660,7 @@ def _add_ensurepath(subparsers: argparse._SubParsersAction) -> None:
             "Note that running this may modify "
             "your shell's configuration file(s) such as '~/.bashrc'."
         ),
+        parents=[shared_parser],
     )
     p.add_argument(
         "--force",
@@ -672,7 +673,7 @@ def _add_ensurepath(subparsers: argparse._SubParsersAction) -> None:
     )
 
 
-def _add_environment(subparsers: argparse._SubParsersAction) -> None:
+def _add_environment(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "environment",
         formatter_class=LineWrapRawTextHelpFormatter,
@@ -688,8 +689,9 @@ def _add_environment(subparsers: argparse._SubParsersAction) -> None:
             PIPX_LOG_DIR, PIPX_TRASH_DIR, PIPX_VENV_CACHEDIR, PIPX_DEFAULT_PYTHON, USE_EMOJI
             """
         ),
+        parents=[shared_parser],
     )
-    p.add_argument("--value", "-v", metavar="VARIABLE", help="Print the value of the variable.")
+    p.add_argument("--value", "-V", metavar="VARIABLE", help="Print the value of the variable.")
 
 
 def get_command_parser() -> argparse.ArgumentParser:
@@ -706,26 +708,42 @@ def get_command_parser() -> argparse.ArgumentParser:
 
     subparsers = parser.add_subparsers(dest="command", description="Get help for commands with pipx COMMAND --help")
 
-    _add_install(subparsers)
-    _add_uninject(subparsers, completer_venvs.use)
-    _add_inject(subparsers, completer_venvs.use)
-    _add_upgrade(subparsers, completer_venvs.use)
-    _add_upgrade_all(subparsers)
-    _add_uninstall(subparsers, completer_venvs.use)
-    _add_uninstall_all(subparsers)
-    _add_reinstall(subparsers, completer_venvs.use)
-    _add_reinstall_all(subparsers)
-    _add_list(subparsers)
-    _add_run(subparsers)
-    _add_runpip(subparsers, completer_venvs.use)
-    _add_ensurepath(subparsers)
-    _add_environment(subparsers)
+    shared_parser = argparse.ArgumentParser(add_help=False)
+
+    shared_parser.add_argument(
+        "--quiet",
+        "-q",
+        action="count",
+        default=0,
+        help=(
+            "Give less output. May be used multiple times corresponding to the"
+            " WARNING, ERROR, and CRITICAL logging levels."
+        ),
+    )
+
+    shared_parser.add_argument("--verbose", "-v", action="count", default=0, help=("Give more output."))
+
+    _add_install(subparsers, shared_parser)
+    _add_uninject(subparsers, completer_venvs.use, shared_parser)
+    _add_inject(subparsers, completer_venvs.use, shared_parser)
+    _add_upgrade(subparsers, completer_venvs.use, shared_parser)
+    _add_upgrade_all(subparsers, shared_parser)
+    _add_uninstall(subparsers, completer_venvs.use, shared_parser)
+    _add_uninstall_all(subparsers, shared_parser)
+    _add_reinstall(subparsers, completer_venvs.use, shared_parser)
+    _add_reinstall_all(subparsers, shared_parser)
+    _add_list(subparsers, shared_parser)
+    _add_run(subparsers, shared_parser)
+    _add_runpip(subparsers, completer_venvs.use, shared_parser)
+    _add_ensurepath(subparsers, shared_parser)
+    _add_environment(subparsers, shared_parser)
 
     parser.add_argument("--version", action="store_true", help="Print version and exit")
     subparsers.add_parser(
         "completions",
         help="Print instructions on enabling shell completions for pipx",
         description="Print instructions on enabling shell completions for pipx",
+        parents=[shared_parser],
     )
     return parser
 
@@ -772,6 +790,11 @@ def setup_logging(verbose: bool) -> None:
     pipx_str = bold(green("pipx >")) if sys.stdout.isatty() else "pipx >"
     pipx.constants.pipx_log_file = setup_log_file()
 
+    # Determine logging level
+    level_number = max(0, 2 - verbose) * 10
+
+    level = logging.getLevelName(level_number)
+
     # "incremental" is False so previous pytest tests don't accumulate handlers
     logging_config = {
         "version": 1,
@@ -796,7 +819,7 @@ def setup_logging(verbose: bool) -> None:
             "stream": {
                 "class": "logging.StreamHandler",
                 "formatter": "stream_verbose" if verbose else "stream_nonverbose",
-                "level": "INFO" if verbose else "WARNING",
+                "level": level,
             },
             "file": {
                 "class": "logging.FileHandler",
@@ -817,7 +840,9 @@ def setup(args: argparse.Namespace) -> None:
         print_version()
         sys.exit(0)
 
-    setup_logging("verbose" in args and args.verbose)
+    verbose = args.verbose - args.quiet
+
+    setup_logging(verbose)
 
     logger.debug(f"{time.strftime('%Y-%m-%d %H:%M:%S')}")
     logger.debug(f"{' '.join(sys.argv)}")


Test patch:
diff --git a/.github/workflows/create_tests_package_lists.yml b/.github/workflows/create_tests_package_lists.yml
index b294a6c..8e4edf3 100644
--- a/.github/workflows/create_tests_package_lists.yml
+++ b/.github/workflows/create_tests_package_lists.yml
@@ -30,7 +30,7 @@ jobs:
         run:
           nox --non-interactive --session create_test_package_list-${{ matrix.python-version }} -- ./new_tests_packages
       - name: Store reports as artifacts
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v3
         with:
           name: lists
           path: ./new_tests_packages
diff --git a/.github/workflows/exhaustive_package_test.yml b/.github/workflows/exhaustive_package_test.yml
index fb3e491..9fa850c 100644
--- a/.github/workflows/exhaustive_package_test.yml
+++ b/.github/workflows/exhaustive_package_test.yml
@@ -27,7 +27,7 @@ jobs:
         continue-on-error: true
         run: nox --non-interactive --session test_all_packages-${{ matrix.python-version }}
       - name: Store reports as artifacts
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v3
         with:
           name: reports-raw
           path: reports
@@ -39,7 +39,7 @@ jobs:
 
     steps:
       - name: Get report artifacts
-        uses: actions/download-artifact@v4
+        uses: actions/download-artifact@v3
         with:
           name: reports-raw
       - name: Collate reports
@@ -57,7 +57,7 @@ jobs:
           cat all_packages_deps_errors_* > all_deps_errors_lf.txt
           tr -d '\r' < all_deps_errors_lf.txt > reports/all_deps_errors.txt
       - name: Store collated and raw reports as artifacts
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v3
         with:
           name: reports-final
           path: reports
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 89689d9..e42b388 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -77,7 +77,7 @@ jobs:
         run: nox --error-on-missing-interpreters --non-interactive --session zipapp
       - name: Test zipapp by installing black
         run: python ./pipx.pyz install black
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v3
         with:
           name: pipx.pyz
           path: pipx.pyz
@@ -114,7 +114,7 @@ jobs:
     needs: [tests, man, zipapp]
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/download-artifact@v4
+      - uses: actions/download-artifact@v3
         with:
           name: pipx.pyz
       - name: Upload to release
diff --git a/tests/test_list.py b/tests/test_list.py
index 9b6ef40..59396ea 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -80,7 +80,7 @@ def test_list_json(pipx_temp_env, capsys):
     assert not run_pipx_cli(["inject", "pylint", PKG["isort"]["spec"]])
     captured = capsys.readouterr()
 
-    assert not run_pipx_cli(["list", "--json"])
+    assert not run_pipx_cli(["list", "-q", "--json"])
     captured = capsys.readouterr()
 
     assert not re.search(r"\S", captured.err)