Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add source interpreter to pipx metadata #1251

Merged
merged 8 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/pipx/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
20 changes: 16 additions & 4 deletions src/pipx/pipx_metadata_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
chrysle marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, venv_dir: Path, read: bool = True):
self.venv_dir = venv_dir
Expand All @@ -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] = {}

Expand All @@ -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"""
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions src/pipx/standalone_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/pipx/venv.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import re
import shutil
import time
from pathlib import Path
from subprocess import CompletedProcess
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import shutil
import socket
Expand All @@ -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")
Expand All @@ -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",
Expand Down
24 changes: 20 additions & 4 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.4",
Gitznik marked this conversation as resolved.
Show resolved Hide resolved
"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,
Expand Down Expand Up @@ -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
Expand All @@ -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":
Expand All @@ -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)
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions tests/test_inject.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Expand Down
13 changes: 1 addition & 12 deletions tests/test_interpreter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
import shutil
import subprocess
import sys
Expand All @@ -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):
Expand Down Expand Up @@ -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)
43 changes: 40 additions & 3 deletions tests/test_list.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"]])
Expand All @@ -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"])
Expand Down
3 changes: 3 additions & 0 deletions tests/test_pipx_metadata_file.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
from pathlib import Path

import pytest # type: ignore
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = {}

Expand Down
Loading