Repository: pypa/pipx (python)

Issue:
Option for the `list` command not to go out to the internet
**How would this feature be useful?**

When using pipx while the system has no internet connection, `pipx list` takes an awful long time because before printing teh list of packages it tries to upgrade the shared libraries.

**Describe the solution you'd like**

The main purpose of the `list` command is to list what's there, so if I know I have no internet I should be able to say "_Just give me the list. I don't care about doing other maintenance things._" because these maintenance things won't work in any case.

**Describe the solution you'd like**
An option like `--no-maintenance` or `--dont-update` for the `list` command.


Pull request:
Allow skipping maintenance tasks for list command
- [X] I have added an entry to `docs/changelog.md`

## Summary of changes
Add a `--skip-maintenance` flag to the list command. This sets a new state on the `_SharedLibs` class, that is used to skip the shared libs upgrade step by setting `needs_upgrade` to False. This change allows the user to skip the functionality that reaches out to `pip`, which makes executing the command a lot faster when offline.
resolves #1081 

## Test plan

Introduced a new test case `test_skip_maintenance`. It first adds some packages to introduce shared libs. It then verifies that a normal `list` command sets the expected state on the `shared_libs` object. Then it verifies that running `list` with `--skip-maintenance` results in state on the `shared_libs` object that indicates that no upgrade was performed.

Tested by running `nox -s tests-3.12`

```
pipx list --skip-maintenance
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21a8ff2..a34c371 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## dev
 
+- Allow skipping maintenance tasks during list command
+ 
 ## 1.4.1
 
 - Set default logging level to WARNING, so debug log messages won't be shown without passing additional flags such as `--verbose`
diff --git a/src/pipx/commands/list_packages.py b/src/pipx/commands/list_packages.py
index ba584d3..dba22e7 100644
--- a/src/pipx/commands/list_packages.py
+++ b/src/pipx/commands/list_packages.py
@@ -4,7 +4,7 @@ import sys
 from pathlib import Path
 from typing import Any, Collection, Dict, Tuple
 
-from pipx import constants
+from pipx import constants, shared_libs
 from pipx.colors import bold
 from pipx.commands.common import VenvProblems, get_venv_summary, venv_health_check
 from pipx.constants import EXIT_CODE_LIST_PROBLEM, EXIT_CODE_OK, ExitCode
@@ -89,12 +89,17 @@ def list_packages(
     include_injected: bool,
     json_format: bool,
     short_format: bool,
+    skip_maintenance: bool,
 ) -> ExitCode:
     """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}", file=sys.stderr)
 
+    if skip_maintenance:
+        shared_libs.shared_libs.skip_upgrade = True
+        logger.info("Skipping shared libs maintenance tasks")
+
     venv_container.verify_shared_libs()
 
     if json_format:
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 1ad7b6b..49c7fb3 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -263,7 +263,9 @@ def run_pipx_command(args: argparse.Namespace) -> ExitCode:  # noqa: C901
             force=args.force,
         )
     elif args.command == "list":
-        return commands.list_packages(venv_container, args.include_injected, args.json, args.short)
+        return commands.list_packages(
+            venv_container, args.include_injected, args.json, args.short, args.skip_maintenance
+        )
     elif args.command == "uninstall":
         return commands.uninstall(venv_dir, constants.LOCAL_BIN_DIR, constants.LOCAL_MAN_DIR, verbose)
     elif args.command == "uninstall-all":
@@ -566,6 +568,7 @@ def _add_list(subparsers: argparse._SubParsersAction, shared_parser: argparse.Ar
     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.")
+    g.add_argument("--skip-maintenance", action="store_true", help="Skip maintenance tasks.")
 
 
 def _add_run(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
diff --git a/src/pipx/shared_libs.py b/src/pipx/shared_libs.py
index 366e84a..9429b01 100644
--- a/src/pipx/shared_libs.py
+++ b/src/pipx/shared_libs.py
@@ -31,6 +31,7 @@ class _SharedLibs:
         self._site_packages: Optional[Path] = None
         self.has_been_updated_this_run = False
         self.has_been_logged_this_run = False
+        self.skip_upgrade = False
 
     @property
     def site_packages(self) -> Path:
@@ -65,7 +66,7 @@ class _SharedLibs:
 
     @property
     def needs_upgrade(self) -> bool:
-        if self.has_been_updated_this_run:
+        if self.has_been_updated_this_run or self.skip_upgrade:
             return False
 
         if not self.pip_path.is_file():


Test patch:
diff --git a/tests/test_list.py b/tests/test_list.py
index 9b6ef40..f6db6d1 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -1,5 +1,7 @@
 import json
+import os
 import re
+import time
 
 import pytest  # type: ignore
 
@@ -12,7 +14,7 @@ from helpers import (
     run_pipx_cli,
 )
 from package_info import PKG
-from pipx import constants
+from pipx import constants, shared_libs
 from pipx.pipx_metadata_file import PackageInfo, _json_decoder_object_hook
 
 
@@ -128,3 +130,27 @@ def test_list_short(pipx_temp_env, monkeypatch, capsys):
 
     assert "pycowsay 0.0.0.2" in captured.out
     assert "pylint 2.3.1" in captured.out
+
+
+def test_skip_maintenance(pipx_temp_env):
+    assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
+    assert not run_pipx_cli(["install", PKG["pylint"]["spec"]])
+
+    now = time.time()
+    shared_libs.shared_libs.create(verbose=True)
+    shared_libs.shared_libs.has_been_updated_this_run = False
+
+    access_time = now  # this can be anything
+    os.utime(shared_libs.shared_libs.pip_path, (access_time, -shared_libs.SHARED_LIBS_MAX_AGE_SEC - 5 * 60 + now))
+    assert shared_libs.shared_libs.needs_upgrade
+    run_pipx_cli(["list"])
+    assert shared_libs.shared_libs.has_been_updated_this_run
+    assert not shared_libs.shared_libs.needs_upgrade
+
+    os.utime(shared_libs.shared_libs.pip_path, (access_time, -shared_libs.SHARED_LIBS_MAX_AGE_SEC - 5 * 60 + now))
+    shared_libs.shared_libs.has_been_updated_this_run = False
+    assert shared_libs.shared_libs.needs_upgrade
+    run_pipx_cli(["list", "--skip-maintenance"])
+    shared_libs.shared_libs.skip_upgrade = False
+    assert not shared_libs.shared_libs.has_been_updated_this_run
+    assert shared_libs.shared_libs.needs_upgrade