diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py index 62fd0de8df..a658369edc 100644 --- a/src/pipx/commands/common.py +++ b/src/pipx/commands/common.py @@ -15,7 +15,7 @@ from pipx import constants from pipx.colors import bold, red -from pipx.constants import MAN_SECTIONS, WINDOWS +from pipx.constants import MAN_SECTIONS, PIPX_STANDALONE_PYTHON_CACHEDIR, WINDOWS from pipx.emojis import hazard, stars from pipx.package_specifier import parse_specifier_for_install, valid_pypi_name from pipx.pipx_metadata_file import PackageInfo @@ -250,9 +250,16 @@ def get_venv_summary( # The following is to satisfy mypy that python_version is str and not # Optional[str] python_version = venv.pipx_metadata.python_version if venv.pipx_metadata.python_version is not None else "" + source_interpreter = venv.pipx_metadata.source_interpreter + is_standalone = ( + str(source_interpreter).startswith(str(PIPX_STANDALONE_PYTHON_CACHEDIR.resolve())) + if source_interpreter + else False + ) return ( _get_list_output( python_version, + is_standalone, package_metadata.package_version, package_name, new_install, @@ -322,6 +329,7 @@ def get_exposed_man_paths_for_package( def _get_list_output( python_version: str, + python_is_standalone: bool, package_version: str, package_name: str, new_install: bool, @@ -337,6 +345,7 @@ def _get_list_output( output.append( f" {'installed' if new_install else ''} package {bold(shlex.quote(package_name))}" f" {bold(package_version)}{suffix}, installed using {python_version}" + + (" (standalone)" if python_is_standalone else "") ) if new_install and (exposed_binary_names or unavailable_binary_names): diff --git a/src/pipx/pipx_metadata_file.py b/src/pipx/pipx_metadata_file.py index 196b43a602..7d04062af5 100644 --- a/src/pipx/pipx_metadata_file.py +++ b/src/pipx/pipx_metadata_file.py @@ -46,7 +46,11 @@ class PackageInfo(NamedTuple): class PipxMetadata: # Only change this if file format changes - __METADATA_VERSION__: str = "0.3" + # V0.1 -> original version + # V0.2 -> Improve handling of suffixes + # V0.3 -> Add man pages fields + # V0.4 -> Add source interpreter + __METADATA_VERSION__: str = "0.4" def __init__(self, venv_dir: Path, read: bool = True): self.venv_dir = venv_dir @@ -72,6 +76,7 @@ def __init__(self, venv_dir: Path, read: bool = True): package_version="", ) self.python_version: Optional[str] = None + self.source_interpreter: Optional[Path] = None self.venv_args: List[str] = [] self.injected_packages: Dict[str, PackageInfo] = {} @@ -82,20 +87,23 @@ def to_dict(self) -> Dict[str, Any]: return { "main_package": self.main_package._asdict(), "python_version": self.python_version, + "source_interpreter": self.source_interpreter, "venv_args": self.venv_args, "injected_packages": {name: data._asdict() for (name, data) in self.injected_packages.items()}, "pipx_metadata_version": self.__METADATA_VERSION__, } def _convert_legacy_metadata(self, metadata_dict: Dict[str, Any]) -> Dict[str, Any]: - if metadata_dict["pipx_metadata_version"] in ("0.2", self.__METADATA_VERSION__): - return metadata_dict + if metadata_dict["pipx_metadata_version"] in (self.__METADATA_VERSION__): + pass + elif metadata_dict["pipx_metadata_version"] in ("0.2", "0.3"): + metadata_dict["source_interpreter"] = None elif metadata_dict["pipx_metadata_version"] == "0.1": main_package_data = metadata_dict["main_package"] if main_package_data["package"] != self.venv_dir.name: # handle older suffixed packages gracefully main_package_data["suffix"] = self.venv_dir.name.replace(main_package_data["package"], "") - return metadata_dict + metadata_dict["source_interpreter"] = None else: raise PipxError( f""" @@ -104,11 +112,15 @@ def _convert_legacy_metadata(self, metadata_dict: Dict[str, Any]) -> Dict[str, A installed with a later version of pipx. """ ) + return metadata_dict def from_dict(self, input_dict: Dict[str, Any]) -> None: input_dict = self._convert_legacy_metadata(input_dict) self.main_package = PackageInfo(**input_dict["main_package"]) self.python_version = input_dict["python_version"] + self.source_interpreter = ( + Path(input_dict["source_interpreter"]) if input_dict.get("source_interpreter") else None + ) self.venv_args = input_dict["venv_args"] self.injected_packages = { f"{name}{data.get('suffix', '')}": PackageInfo(**data) diff --git a/src/pipx/standalone_python.py b/src/pipx/standalone_python.py index bf0e83941a..65e5065bcd 100644 --- a/src/pipx/standalone_python.py +++ b/src/pipx/standalone_python.py @@ -57,10 +57,9 @@ def download_python_build_standalone(python_version: str): # python_version can be a bare version number like "3.9" or a "binary name" like python3.10 # we'll convert it to a bare version number python_version = re.sub(r"[c]?python", "", python_version) - python_bin = "python.exe" if WINDOWS else "python3" install_dir = PIPX_STANDALONE_PYTHON_CACHEDIR / python_version - installed_python = install_dir / "bin" / python_bin + installed_python = install_dir / "python.exe" if WINDOWS else install_dir / "bin" / "python3" if installed_python.exists(): return str(installed_python) diff --git a/src/pipx/venv.py b/src/pipx/venv.py index 96de4e3731..0416ad78bd 100644 --- a/src/pipx/venv.py +++ b/src/pipx/venv.py @@ -1,6 +1,7 @@ import json import logging import re +import shutil import time from pathlib import Path from subprocess import CompletedProcess @@ -176,6 +177,9 @@ def create_venv(self, venv_args: List[str], pip_args: List[str], override_shared self.pipx_metadata.venv_args = venv_args self.pipx_metadata.python_version = self.get_python_version() + source_interpreter = shutil.which(self.python) + if source_interpreter: + self.pipx_metadata.source_interpreter = Path(source_interpreter) def safe_to_remove(self) -> bool: return not self._existing diff --git a/tests/conftest.py b/tests/conftest.py index af7a29c833..92bef0871c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import json import os import shutil import socket @@ -13,7 +14,7 @@ import pytest # type: ignore from helpers import WIN -from pipx import commands, constants, interpreter, shared_libs, venv +from pipx import commands, constants, interpreter, shared_libs, standalone_python, venv PIPX_TESTS_DIR = Path(".pipx_tests") PIPX_TESTS_PACKAGE_LIST_DIR = Path("testdata/tests_packages") @@ -24,6 +25,17 @@ def root() -> Path: return Path(__file__).parents[1] +@pytest.fixture() +def mocked_github_api(monkeypatch, root): + """ + Fixture to replace the github index with a local copy, + to prevent unit tests from exceeding github's API request limit. + """ + with open(root / "testdata" / "standalone_python_index.json") as f: + index = json.load(f) + monkeypatch.setattr(standalone_python, "get_or_update_index", lambda: index) + + def pytest_addoption(parser): parser.addoption( "--all-packages", diff --git a/tests/helpers.py b/tests/helpers.py index be0fcbd439..ad3b8df568 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -13,6 +13,8 @@ WIN = sys.platform.startswith("win") +PIPX_METADATA_LEGACY_VERSIONS = [None, "0.1", "0.2", "0.3"] + MOCK_PIPXMETADATA_0_1: Dict[str, Any] = { "main_package": None, "python_version": None, @@ -29,6 +31,18 @@ "pipx_metadata_version": "0.2", } +MOCK_PIPXMETADATA_0_3: Dict[str, Any] = { + "main_package": None, + "python_version": None, + "venv_args": [], + "injected_packages": {}, + "pipx_metadata_version": "0.3", + "man_pages": [], + "man_paths": [], + "man_pages_of_dependencies": [], + "man_paths_of_dependencies": {}, +} + MOCK_PACKAGE_INFO_0_1: Dict[str, Any] = { "package": None, "package_or_url": None, @@ -77,7 +91,7 @@ def unwrap_log_text(log_text: str): def _mock_legacy_package_info(modern_package_info: Dict[str, Any], metadata_version: str) -> Dict[str, Any]: - if metadata_version == "0.2": + if metadata_version in ["0.2", "0.3"]: mock_package_info_template = MOCK_PACKAGE_INFO_0_2 elif metadata_version == "0.1": mock_package_info_template = MOCK_PACKAGE_INFO_0_1 @@ -98,9 +112,11 @@ def mock_legacy_venv(venv_name: str, metadata_version: Optional[str] = None) -> """ venv_dir = Path(constants.PIPX_LOCAL_VENVS) / canonicalize_name(venv_name) - if metadata_version == "0.3": + if metadata_version == "0.4": # Current metadata version, do nothing return + elif metadata_version == "0.3": + mock_pipx_metadata_template = MOCK_PIPXMETADATA_0_3 elif metadata_version == "0.2": mock_pipx_metadata_template = MOCK_PIPXMETADATA_0_2 elif metadata_version == "0.1": @@ -115,7 +131,7 @@ def mock_legacy_venv(venv_name: str, metadata_version: Optional[str] = None) -> modern_metadata = pipx_metadata_file.PipxMetadata(venv_dir).to_dict() # Convert to mock old metadata - mock_pipx_metadata = {} + mock_pipx_metadata: dict[str, Any] = {} for key in mock_pipx_metadata_template: if key == "main_package": mock_pipx_metadata[key] = _mock_legacy_package_info(modern_metadata[key], metadata_version=metadata_version) @@ -126,7 +142,7 @@ def mock_legacy_venv(venv_name: str, metadata_version: Optional[str] = None) -> modern_metadata[key][injected], metadata_version=metadata_version ) else: - mock_pipx_metadata[key] = modern_metadata[key] + mock_pipx_metadata[key] = modern_metadata.get(key) mock_pipx_metadata["pipx_metadata_version"] = mock_pipx_metadata_template["pipx_metadata_version"] # replicate pipx_metadata_file.PipxMetadata.write() diff --git a/tests/test_inject.py b/tests/test_inject.py index 5bae92bebd..01c4ff04a2 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -1,6 +1,6 @@ import pytest # type: ignore -from helpers import mock_legacy_venv, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli from package_info import PKG @@ -9,7 +9,7 @@ def test_inject_simple(pipx_temp_env, capsys): assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_inject_simple_legacy_venv(pipx_temp_env, capsys, metadata_version): assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index d2dce556d4..aa5c7bbc57 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -1,4 +1,3 @@ -import json import shutil import subprocess import sys @@ -18,17 +17,6 @@ from pipx.util import PipxError -@pytest.fixture() -def mocked_github_api(monkeypatch, root): - """ - Fixture to replace the github index with a local copy, - to prevent unit tests from exceeding github's API request limit. - """ - with open(root / "testdata" / "standalone_python_index.json") as f: - index = json.load(f) - monkeypatch.setattr(pipx.standalone_python, "get_or_update_index", lambda: index) - - @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Looks for Python.exe") @pytest.mark.parametrize("venv", [True, False]) def test_windows_python_with_version(monkeypatch, venv): @@ -207,3 +195,4 @@ def which(name): assert python_path.endswith("python.exe") else: assert python_path.endswith("python3") + subprocess.run([python_path, "-c", "import sys; print(sys.executable)"], check=True) diff --git a/tests/test_list.py b/tests/test_list.py index f6db6d1ce0..15ef990b2c 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -1,11 +1,14 @@ import json import os import re +import shutil +import sys import time import pytest # type: ignore from helpers import ( + PIPX_METADATA_LEGACY_VERSIONS, app_name, assert_package_metadata, create_package_info_ref, @@ -47,7 +50,7 @@ def test_list_suffix(pipx_temp_env, monkeypatch, capsys): assert f"package pycowsay 0.0.0.2 (pycowsay{suffix})," in captured.out -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_list_legacy_venv(pipx_temp_env, monkeypatch, capsys, metadata_version): assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) @@ -132,6 +135,34 @@ def test_list_short(pipx_temp_env, monkeypatch, capsys): assert "pylint 2.3.1" in captured.out +def test_list_standalone_interpreter(pipx_temp_env, monkeypatch, mocked_github_api, capsys): + def which(name): + return None + + monkeypatch.setattr(shutil, "which", which) + + major = sys.version_info.major + # Minor version 3.8 is not supported for fetching standalone versions + minor = sys.version_info.minor if sys.version_info.minor != 8 else 9 + target_python = f"{major}.{minor}" + + assert not run_pipx_cli( + [ + "install", + "--fetch-missing-python", + "--python", + target_python, + PKG["pycowsay"]["spec"], + ] + ) + captured = capsys.readouterr() + + assert not run_pipx_cli(["list"]) + captured = capsys.readouterr() + + assert "standalone" 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"]]) @@ -141,13 +172,19 @@ def test_skip_maintenance(pipx_temp_env): 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)) + 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)) + 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"]) diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py index a6e9c7f7c4..11e3aeb4eb 100644 --- a/tests/test_pipx_metadata_file.py +++ b/tests/test_pipx_metadata_file.py @@ -1,3 +1,4 @@ +import sys from pathlib import Path import pytest # type: ignore @@ -47,6 +48,7 @@ def test_pipx_metadata_file_create(tmp_path): pipx_metadata = PipxMetadata(venv_dir) pipx_metadata.main_package = TEST_PACKAGE1 pipx_metadata.python_version = "3.4.5" + pipx_metadata.source_interpreter = Path(sys.executable) pipx_metadata.venv_args = ["--system-site-packages"] pipx_metadata.injected_packages = {"injected": TEST_PACKAGE2} pipx_metadata.write() @@ -78,6 +80,7 @@ def test_pipx_metadata_file_validation(tmp_path, test_package): pipx_metadata = PipxMetadata(venv_dir) pipx_metadata.main_package = test_package pipx_metadata.python_version = "3.4.5" + pipx_metadata.source_interpreter = Path(sys.executable) pipx_metadata.venv_args = ["--system-site-packages"] pipx_metadata.injected_packages = {} diff --git a/tests/test_reinstall.py b/tests/test_reinstall.py index bcfc2cc5ab..ce2d7ec4f1 100644 --- a/tests/test_reinstall.py +++ b/tests/test_reinstall.py @@ -2,7 +2,7 @@ import pytest # type: ignore -from helpers import mock_legacy_venv, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli def test_reinstall(pipx_temp_env, capsys): @@ -15,7 +15,7 @@ def test_reinstall_nonexistent(pipx_temp_env, capsys): assert "Nothing to reinstall for nonexistent" in capsys.readouterr().out -@pytest.mark.parametrize("metadata_version", [None, "0.1"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_reinstall_legacy_venv(pipx_temp_env, capsys, metadata_version): assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) diff --git a/tests/test_reinstall_all.py b/tests/test_reinstall_all.py index d8041a60ad..9139ffb5c3 100644 --- a/tests/test_reinstall_all.py +++ b/tests/test_reinstall_all.py @@ -2,7 +2,7 @@ import pytest # type: ignore -from helpers import mock_legacy_venv, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli def test_reinstall_all(pipx_temp_env, capsys): @@ -10,7 +10,7 @@ def test_reinstall_all(pipx_temp_env, capsys): assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_reinstall_all_legacy_venv(pipx_temp_env, capsys, metadata_version): assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index a48f7f97f5..42cbd2ad4c 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -2,7 +2,7 @@ import pytest # type: ignore -from helpers import app_name, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, app_name, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli from package_info import PKG from pipx import constants @@ -27,7 +27,7 @@ def test_uninstall_circular_deps(pipx_temp_env): assert not run_pipx_cli(["uninstall", "cloudtoken"]) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_uninstall_legacy_venv(pipx_temp_env, metadata_version): executable_path = constants.LOCAL_BIN_DIR / app_name("pycowsay") @@ -100,7 +100,7 @@ def test_uninstall_suffix_legacy_venv(pipx_temp_env, metadata_version): assert not file_or_symlink(executable_path) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_uninstall_with_missing_interpreter(pipx_temp_env, metadata_version): executable_path = constants.LOCAL_BIN_DIR / app_name("pycowsay") @@ -116,7 +116,7 @@ def test_uninstall_with_missing_interpreter(pipx_temp_env, metadata_version): assert not file_or_symlink(executable_path) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_uninstall_proper_dep_behavior(pipx_temp_env, metadata_version): # isort is a dependency of pylint. Make sure that uninstalling pylint # does not also uninstall isort app in LOCAL_BIN_DIR @@ -141,7 +141,7 @@ def test_uninstall_proper_dep_behavior(pipx_temp_env, metadata_version): assert isort_app_path.exists() -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_uninstall_proper_dep_behavior_missing_interpreter(pipx_temp_env, metadata_version): # isort is a dependency of pylint. Make sure that uninstalling pylint # does not also uninstall isort app in LOCAL_BIN_DIR diff --git a/tests/test_uninstall_all.py b/tests/test_uninstall_all.py index aba784ed53..74a2b09c45 100644 --- a/tests/test_uninstall_all.py +++ b/tests/test_uninstall_all.py @@ -1,6 +1,6 @@ import pytest # type: ignore -from helpers import mock_legacy_venv, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli def test_uninstall_all(pipx_temp_env, capsys): @@ -8,7 +8,7 @@ def test_uninstall_all(pipx_temp_env, capsys): assert not run_pipx_cli(["uninstall-all"]) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_uninstall_all_legacy_venv(pipx_temp_env, capsys, metadata_version): assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index a8b5068eb3..816b7c4ed6 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -1,6 +1,6 @@ import pytest # type: ignore -from helpers import mock_legacy_venv, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli from package_info import PKG @@ -10,7 +10,7 @@ def test_upgrade(pipx_temp_env, capsys): assert not run_pipx_cli(["upgrade", "pycowsay"]) -@pytest.mark.parametrize("metadata_version", [None, "0.1"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_upgrade_legacy_venv(pipx_temp_env, capsys, metadata_version): assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) diff --git a/tests/test_upgrade_all.py b/tests/test_upgrade_all.py index fc32ca1b58..6479b99444 100644 --- a/tests/test_upgrade_all.py +++ b/tests/test_upgrade_all.py @@ -1,6 +1,6 @@ import pytest # type: ignore -from helpers import mock_legacy_venv, run_pipx_cli +from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli def test_upgrade_all(pipx_temp_env, capsys): @@ -9,7 +9,7 @@ def test_upgrade_all(pipx_temp_env, capsys): assert not run_pipx_cli(["upgrade-all"]) -@pytest.mark.parametrize("metadata_version", [None, "0.1", "0.2"]) +@pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) def test_upgrade_all_legacy_venv(pipx_temp_env, capsys, caplog, metadata_version): assert run_pipx_cli(["upgrade", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay"])