diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 501651b08..642663b04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,7 @@ repos: - "packaging>=20" - "platformdirs>=2.1" - "tomli; python_version < '3.11'" + - "pytest" # Configuration for codespell is in pyproject.toml - repo: https://github.com/codespell-project/codespell rev: v2.3.0 diff --git a/changelog.d/1606.feature.md b/changelog.d/1606.feature.md new file mode 100644 index 000000000..6cdcb2b15 --- /dev/null +++ b/changelog.d/1606.feature.md @@ -0,0 +1,8 @@ +Add `pipx clean` command to remove pipx data. + +The new command supports selective cleanup with `--cache`, `--logs`, +`--trash`, and `--venvs` flags to remove specific components. Running +`pipx clean` without flags performs a full cleanup, removing all pipx +data and resetting to a fresh installation state. All cleanup operations +require user confirmation unless the `--force` flag is used. Use +`--verbose` for detailed output including paths and progress information. diff --git a/docs/examples.md b/docs/examples.md index 2c79c5483..44e64a513 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -187,3 +187,51 @@ This example pins `pip` (temporarily, until the next automatic upgrade, if that ```shell > pipx upgrade-shared --pip-args=pip==24.0 ``` + +## `pipx clean` examples + +The `clean` command helps you free up disk space by removing pipx data. You can selectively clean specific components or perform a complete cleanup. + +### Clean specific components + +Remove only the cache (temporary virtual environments from `pipx run`): + +``` +pipx clean --cache +``` + +Remove only log files: + +``` +pipx clean --logs +``` + +Empty the trash directory: + +``` +pipx clean --trash +``` + +Remove all installed packages and their virtual environments: + +``` +pipx clean --venvs +``` + +### Clean multiple components + +You can combine flags to clean multiple components at once: + +``` +pipx clean --cache --logs --trash +``` + +### Full cleanup + +Remove **all** pipx data (this will uninstall all packages and reset pipx to a fresh state): + +``` +pipx clean +``` + +> ⚠️ **Warning**: Running `pipx clean` without any flags will remove ALL pipx data, including all installed packages. This operation requires confirmation unless the `--force` flag is used. diff --git a/src/pipx/commands/__init__.py b/src/pipx/commands/__init__.py index fb37022b1..b03b09c32 100644 --- a/src/pipx/commands/__init__.py +++ b/src/pipx/commands/__init__.py @@ -1,3 +1,4 @@ +from pipx.commands.clean_cmd import clean from pipx.commands.ensure_path import ensure_pipx_paths from pipx.commands.environment import environment from pipx.commands.inject import inject @@ -13,6 +14,7 @@ from pipx.commands.upgrade import upgrade, upgrade_all, upgrade_shared __all__ = [ + "clean", "ensure_pipx_paths", "environment", "inject", diff --git a/src/pipx/commands/clean_cmd.py b/src/pipx/commands/clean_cmd.py new file mode 100644 index 000000000..1299f92cf --- /dev/null +++ b/src/pipx/commands/clean_cmd.py @@ -0,0 +1,225 @@ +"""Clean command for pipx CLI.""" + +from pathlib import Path + +from pipx.colors import bold, red +from pipx.constants import ( + EXIT_CODE_CACHE_CLEANUP_FAIL, + EXIT_CODE_FULL_CLEANUP_FAIL, + EXIT_CODE_LOGS_CLEANUP_FAIL, + EXIT_CODE_OK, + EXIT_CODE_TRASH_CLEANUP_FAIL, + EXIT_CODE_VENVS_CLEANUP_FAIL, + ExitCode, +) +from pipx.emojis import hazard, stars +from pipx.paths import ctx +from pipx.util import rmdir +from pipx.venv import VenvContainer + + +def _cleanup_directory( + path: Path, + description: str, + error_code: ExitCode, + verbose: bool, +) -> ExitCode: + """ + Remove a directory with standardized error handling and output. + + Args: + path: Directory path to remove + description: User-friendly description of what's being removed + error_code: Exit code to return on failure + verbose: Whether to print detailed output + + Returns: + EXIT_CODE_OK on success, error_code on failure + """ + if not path.exists(): + if verbose: + print(f"Skipping {description} (directory doesn't exist)") + return EXIT_CODE_OK + + action = f"Removing {description}..." + if verbose: + print(f"{action}") + print(f" Path: {path}") + else: + print(bold(action)) + + try: + rmdir(path, safe_rm=False) + except Exception as e: + print(f"{red(f'Error removing {description}:')} {e}") + return error_code + + print(f"{stars} {description.capitalize()} removed.") + return EXIT_CODE_OK + + +def _confirm_action(message: str) -> bool: + """ + Prompt user for confirmation. + + Args: + message: Question to ask the user + + Returns: + True if user confirms, False otherwise + """ + while True: + response = input(f"{message} [y/N]: ").lower().strip() + if response in ("y", "yes"): + return True + if response in ("n", "no", ""): + return False + print("Please answer 'y' or 'n'") + + +def _full_cleanup(verbose: bool) -> ExitCode: + """ + Remove all pipx data, resetting to first installation state. + + This is the nuclear option - removes everything including: + - All installed packages (venvs) + - Cache and temporary data + - Logs + - Trash + - Shared libraries + + Args: + verbose: Print detailed output + force: Skip confirmation prompt + """ + print(f"{hazard} {red('WARNING')}: This will remove ALL pipx data!") + print(red("All installed packages will be lost.")) + print() + + return _cleanup_directory( + path=ctx.home, + description="all pipx data", + error_code=EXIT_CODE_FULL_CLEANUP_FAIL, + verbose=verbose, + ) + + +def _cache_cleanup(verbose: bool) -> ExitCode: + """Remove cached virtual environments from 'pipx run' commands.""" + return _cleanup_directory( + path=ctx.venv_cache, + description="cache and temporary data", + error_code=EXIT_CODE_CACHE_CLEANUP_FAIL, + verbose=verbose, + ) + + +def _logs_cleanup(verbose: bool) -> ExitCode: + """Remove pipx log files.""" + return _cleanup_directory( + path=ctx.logs, + description="logs", + error_code=EXIT_CODE_LOGS_CLEANUP_FAIL, + verbose=verbose, + ) + + +def _trash_cleanup(verbose: bool) -> ExitCode: + """Remove files in the trash directory.""" + return _cleanup_directory( + path=ctx.trash, + description="trash", + error_code=EXIT_CODE_TRASH_CLEANUP_FAIL, + verbose=verbose, + ) + + +def _venvs_cleanup(verbose: bool) -> ExitCode: + """Remove all installed packages and their virtual environments.""" + venv_container = VenvContainer(ctx.venvs) + venv_dirs = list(venv_container.iter_venv_dirs()) + + if not venv_dirs: + if verbose: + print("No installed packages to remove.") + return EXIT_CODE_OK + + print(bold(f"Removing {len(venv_dirs)} installed package(s)...")) + + failed = [] + for venv_dir in venv_dirs: + package_name = venv_dir.name + try: + if verbose: + print(f" Removing {package_name}...") + rmdir(venv_dir, safe_rm=False) + except Exception as e: + failed.append((package_name, e)) + if verbose: + print(f" {red('Failed')}: {e}") + + if failed: + print(f"{red(f'Failed to remove {len(failed)} package(s):')}") + for package_name, error in failed: + print(f" - {package_name}: {error}") + return EXIT_CODE_VENVS_CLEANUP_FAIL + + rmdir(ctx.venvs, safe_rm=True) + + print(f"{stars} All installed packages removed.") + return EXIT_CODE_OK + + +def clean( + cache: bool = False, + logs: bool = False, + trash: bool = False, + venvs: bool = False, + verbose: bool = False, + force: bool = False, +) -> ExitCode: + """ + Clean pipx data directories. + + If no specific options are provided, performs a full cleanup removing + all pipx data. Otherwise, removes only the specified components. + + Args: + cache: Remove cache and temporary virtual environments + logs: Remove log files + trash: Empty trash directory + venvs: Remove all installed packages + verbose: Print detailed output including paths and progress + + Returns: + Combined exit code (0 if all succeeded, non-zero if any failed) + """ + if not force: + if not _confirm_action("Are you sure you want to continue?"): + print("Operation cancelled.") + return EXIT_CODE_OK + # Determine what to clean + any_selected = cache or logs or trash or venvs + + if not any_selected: + # No specific options: full cleanup + return _full_cleanup(verbose) + + # Selective cleanup: run requested operations + cleanup_operations = [] + if cache: + cleanup_operations.append(_cache_cleanup) + if logs: + cleanup_operations.append(_logs_cleanup) + if trash: + cleanup_operations.append(_trash_cleanup) + if venvs: + cleanup_operations.append(_venvs_cleanup) + + # Execute all operations and combine exit codes + for operation in cleanup_operations: + result: ExitCode = operation(verbose) + if result != EXIT_CODE_OK: + return result + + return EXIT_CODE_OK diff --git a/src/pipx/constants.py b/src/pipx/constants.py index 865a8f000..9232e93f2 100644 --- a/src/pipx/constants.py +++ b/src/pipx/constants.py @@ -23,6 +23,11 @@ EXIT_CODE_REINSTALL_VENV_NONEXISTENT = ExitCode(1) EXIT_CODE_REINSTALL_INVALID_PYTHON = ExitCode(1) EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND = ExitCode(1) +EXIT_CODE_FULL_CLEANUP_FAIL = ExitCode(1) +EXIT_CODE_CACHE_CLEANUP_FAIL = ExitCode(1) +EXIT_CODE_LOGS_CLEANUP_FAIL = ExitCode(1) +EXIT_CODE_TRASH_CLEANUP_FAIL = ExitCode(1) +EXIT_CODE_VENVS_CLEANUP_FAIL = ExitCode(1) def is_windows() -> bool: diff --git a/src/pipx/main.py b/src/pipx/main.py index f1b9569cc..d8c64ec6e 100644 --- a/src/pipx/main.py +++ b/src/pipx/main.py @@ -408,6 +408,15 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar skip=skip_list, python_flag_passed=python_flag_passed, ) + elif args.command == "clean": + return commands.clean( + cache=args.cache, + logs=args.logs, + trash=args.trash, + venvs=args.venvs, + verbose=verbose, + force=args.force, + ) elif args.command == "runpip": if not venv_dir: raise PipxError("Developer error: venv_dir is not defined.") @@ -734,6 +743,53 @@ def _add_reinstall(subparsers, venv_completer: VenvCompleter, shared_parser: arg add_python_options(p) +def _add_clean(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: + """Add the clean command to the parser.""" + p = subparsers.add_parser( + "clean", + help="Completely reset pipx to first installation state", + description=textwrap.dedent( + """ + Removes all installed packages, temporary virtual environments, + and cached data, effectively resetting pipx to its initial state. + The granularity of the cleanup can be controlled using the following options: + --venvs : Remove all installed packages and their virtual environments. + --logs : Remove all pipx log files. + --trash : Empty the pipx trash directory. + --cache : Remove cached data and temporary virtual environments, leaving installed packages intact. + """ + ), + parents=[shared_parser], + ) + p.add_argument( + "--cache", + action="store_true", + help="Remove cached data and temporary virtual environments, leaving installed packages intact", + ) + p.add_argument( + "--logs", + action="store_true", + help="Remove all pipx log files", + ) + p.add_argument( + "--trash", + action="store_true", + help="Empty the pipx trash directory", + ) + p.add_argument( + "--venvs", + action="store_true", + help="Remove all installed packages and their virtual environments", + ) + p.add_argument( + "--force", + "-f", + action="store_true", + help="Skip confirmation prompts (use with caution!)", + ) + p.set_defaults(subparser=p) + + def _add_reinstall_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: p = subparsers.add_parser( "reinstall-all", @@ -994,6 +1050,7 @@ def get_command_parser() -> Tuple[argparse.ArgumentParser, Dict[str, argparse.Ar _add_uninstall_all(subparsers, shared_parser) _add_reinstall(subparsers, completer_venvs.use, shared_parser) _add_reinstall_all(subparsers, shared_parser) + _add_clean(subparsers, shared_parser) _add_list(subparsers, shared_parser) subparsers_with_subcommands["interpreter"] = _add_interpreter(subparsers, shared_parser) _add_run(subparsers, shared_parser) diff --git a/tests/conftest.py b/tests/conftest.py index c0d25a55a..51ea35b09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from urllib.error import HTTPError, URLError from urllib.request import urlopen -import pytest # type: ignore[import-not-found] +import pytest from helpers import WIN from pipx import commands, interpreter, paths, shared_libs, standalone_python, venv diff --git a/tests/helpers.py b/tests/helpers.py index 645f6b6bc..3fd7080f5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional from unittest import mock -import pytest # type: ignore[import-not-found] +import pytest from packaging.utils import canonicalize_name from package_info import PKG diff --git a/tests/test_animate.py b/tests/test_animate.py index 758d2bdb7..e62e4bd71 100644 --- a/tests/test_animate.py +++ b/tests/test_animate.py @@ -1,6 +1,6 @@ import time -import pytest # type: ignore[import-not-found] +import pytest import pipx.animate from pipx.animate import ( diff --git a/tests/test_clean.py b/tests/test_clean.py new file mode 100644 index 000000000..e8618473d --- /dev/null +++ b/tests/test_clean.py @@ -0,0 +1,80 @@ +"""Unit tests for the clean command.""" + +from helpers import ( + run_pipx_cli, +) +from pipx import paths + + +def test_clean_full(pipx_temp_env): + assert not run_pipx_cli(["install", "pycowsay"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert not run_pipx_cli(["clean", "--force"]) + assert not paths.ctx.venvs.exists() + assert not paths.ctx.logs.exists() + assert not paths.ctx.venv_cache.exists() + + +def test_clean_logs(pipx_temp_env): + assert not run_pipx_cli(["install", "pycowsay"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert not run_pipx_cli(["clean", "--logs", "--force"]) + assert paths.ctx.venvs.exists() + assert not paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + + +def test_clean_venvs(pipx_temp_env): + assert not run_pipx_cli(["install", "pycowsay"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert not run_pipx_cli(["clean", "--venvs", "--force"]) + assert not paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + + +def test_clean_cache(pipx_temp_env): + assert not run_pipx_cli(["install", "pycowsay"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert not run_pipx_cli(["clean", "--cache", "--force"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert not paths.ctx.venv_cache.exists() + + +def test_clean_trash(pipx_temp_env): + assert not run_pipx_cli(["install", "pycowsay"]) + trash_path = paths.ctx.trash + trash_path.mkdir(parents=True, exist_ok=True) + (trash_path / "temp_file").touch() + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert trash_path.exists() + assert (trash_path / "temp_file").exists() + assert not run_pipx_cli(["clean", "--trash", "--force"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert not trash_path.exists() + + +def test_all_clean_options(pipx_temp_env, capsys): + assert not run_pipx_cli(["install", "pycowsay"]) + assert paths.ctx.venvs.exists() + assert paths.ctx.logs.exists() + assert paths.ctx.venv_cache.exists() + assert not run_pipx_cli(["clean", "--venvs", "--logs", "--cache", "--trash", "--verbose", "--force"]) + captured = capsys.readouterr() + assert " Path: " in captured.out + assert "Removing pycowsay..." in captured.out + assert "Removing 1 installed package(s)..." in captured.out + assert "All installed packages removed." in captured.out diff --git a/tests/test_emojis.py b/tests/test_emojis.py index d1e9f9f20..d31b8606d 100644 --- a/tests/test_emojis.py +++ b/tests/test_emojis.py @@ -2,7 +2,7 @@ from io import BytesIO, TextIOWrapper from unittest import mock -import pytest # type: ignore[import-not-found] +import pytest from pipx.emojis import use_emojis diff --git a/tests/test_inject.py b/tests/test_inject.py index 625fa79e1..b477886c4 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -2,7 +2,7 @@ import re import textwrap -import pytest # type: ignore[import-not-found] +import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows from package_info import PKG diff --git a/tests/test_install.py b/tests/test_install.py index f068d66d9..52139134f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5,7 +5,7 @@ from pathlib import Path from unittest import mock -import pytest # type: ignore[import-not-found] +import pytest from helpers import app_name, run_pipx_cli, skip_if_windows, unwrap_log_text from package_info import PKG diff --git a/tests/test_install_all_packages.py b/tests/test_install_all_packages.py index b9b3a979d..9c2d41390 100644 --- a/tests/test_install_all_packages.py +++ b/tests/test_install_all_packages.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import List, Optional, Tuple -import pytest # type: ignore[import-not-found] +import pytest from helpers import run_pipx_cli from package_info import PKG diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index c1f76b750..6f0937c7f 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -3,7 +3,7 @@ import sys from unittest.mock import Mock -import pytest # type: ignore[import-not-found] +import pytest import pipx.interpreter import pipx.paths diff --git a/tests/test_list.py b/tests/test_list.py index 61d5a5b88..a1c95b85d 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -5,7 +5,7 @@ import sys import time -import pytest # type: ignore[import-not-found] +import pytest from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, diff --git a/tests/test_main.py b/tests/test_main.py index 7ffec9334..b09dcbad1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,7 @@ import sys from unittest import mock -import pytest # type: ignore[import-not-found] +import pytest from helpers import run_pipx_cli from pipx import main diff --git a/tests/test_package_specifier.py b/tests/test_package_specifier.py index 96af1a499..f325f0c7e 100644 --- a/tests/test_package_specifier.py +++ b/tests/test_package_specifier.py @@ -1,6 +1,6 @@ from pathlib import Path -import pytest # type: ignore[import-not-found] +import pytest from pipx.package_specifier import ( fix_package_name, diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py index c1ff3ede3..61da9e582 100644 --- a/tests/test_pipx_metadata_file.py +++ b/tests/test_pipx_metadata_file.py @@ -2,7 +2,7 @@ from dataclasses import replace from pathlib import Path -import pytest # type: ignore[import-not-found] +import pytest from helpers import assert_package_metadata, create_package_info_ref, run_pipx_cli from package_info import PKG diff --git a/tests/test_reinstall.py b/tests/test_reinstall.py index 838d8c32a..0303ee84a 100644 --- a/tests/test_reinstall.py +++ b/tests/test_reinstall.py @@ -1,6 +1,6 @@ import sys -import pytest # type: ignore[import-not-found] +import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows diff --git a/tests/test_reinstall_all.py b/tests/test_reinstall_all.py index 441ee7eb5..5c2f56706 100644 --- a/tests/test_reinstall_all.py +++ b/tests/test_reinstall_all.py @@ -1,6 +1,6 @@ import sys -import pytest # type: ignore[import-not-found] +import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli from pipx import shared_libs diff --git a/tests/test_run.py b/tests/test_run.py index 84f3828b1..537f69962 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest import mock -import pytest # type: ignore[import-not-found] +import pytest import pipx.main import pipx.util diff --git a/tests/test_shared_libs.py b/tests/test_shared_libs.py index b645d5b48..26b8ce4bf 100644 --- a/tests/test_shared_libs.py +++ b/tests/test_shared_libs.py @@ -1,7 +1,7 @@ import os import time -import pytest # type: ignore[import-not-found] +import pytest from pipx import shared_libs diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index 0618b38fc..131d06e65 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -1,6 +1,6 @@ import sys -import pytest # type: ignore[import-not-found] +import pytest from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, diff --git a/tests/test_uninstall_all.py b/tests/test_uninstall_all.py index 5fa7b60c8..2d048e028 100644 --- a/tests/test_uninstall_all.py +++ b/tests/test_uninstall_all.py @@ -1,4 +1,4 @@ -import pytest # type: ignore[import-not-found] +import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index a991909ca..985968354 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -1,4 +1,4 @@ -import pytest # type: ignore[import-not-found] +import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows from package_info import PKG diff --git a/tests/test_upgrade_all.py b/tests/test_upgrade_all.py index aebafe5e3..957979e2e 100644 --- a/tests/test_upgrade_all.py +++ b/tests/test_upgrade_all.py @@ -1,4 +1,4 @@ -import pytest # type: ignore[import-not-found] +import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli diff --git a/tests/test_upgrade_shared.py b/tests/test_upgrade_shared.py index 6e9179fd8..83b685b17 100644 --- a/tests/test_upgrade_shared.py +++ b/tests/test_upgrade_shared.py @@ -1,6 +1,6 @@ import subprocess -import pytest # type: ignore[import-not-found] +import pytest from helpers import run_pipx_cli