Skip to content

Commit

Permalink
Merge branch 'main' into inject-requirements
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesmyatt authored Feb 16, 2024
2 parents 4727e4e + a6c81ca commit 5aaf87a
Show file tree
Hide file tree
Showing 21 changed files with 134 additions and 49 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repos:
hooks:
- id: pyproject-fmt
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
rev: v0.2.1
hooks:
- id: ruff-format
- id: ruff
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1203.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Let self-managed pipx uninstall itself on windows again.
1 change: 1 addition & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
pipx install pycowsay
pipx install --python python3.10 pycowsay
pipx install --python 3.12 pycowsay
pipx install --fetch-missing-python --python 3.12 pycowsay
pipx install git+https://github.com/psf/black
pipx install git+https://github.com/psf/black.git@branch-name
pipx install git+https://github.com/psf/black.git@git-hash
Expand Down
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
1 change: 1 addition & 0 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ def run_pipx_command(args: argparse.Namespace) -> ExitCode: # noqa: C901
interpreter = find_python_interpreter(args.python, fetch_missing_python=fetch_missing_python)
args.python = interpreter
except InterpreterResolutionError as e:
logger.debug("Failed to resolve interpreter:", exc_info=True)
print(
pipx_wrap(
f"{hazard} {e}",
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"

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
9 changes: 5 additions & 4 deletions src/pipx/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ def rmdir(path: Path, safe_rm: bool = True) -> None:
return

logger.info(f"removing directory {path}")
try:
shutil.rmtree(path)
except FileNotFoundError:
pass
# Windows doesn't let us delete or overwrite files that are being run
# But it does let us rename/move it. To get around this issue, we can move
# the file to a temporary folder (to be deleted at a later time)
# So, if safe_rm is True, we ignore any errors and move the file to the trash with below code
shutil.rmtree(path, ignore_errors=safe_rm)

# move it to be deleted later if it still exists
if path.is_dir():
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.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,
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
Expand Up @@ -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
from package_info import PKG


Expand All @@ -11,7 +11,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)
Loading

0 comments on commit 5aaf87a

Please sign in to comment.