Skip to content

Commit

Permalink
Merge pull request #48 from aj-white/issue-44-pipx-location
Browse files Browse the repository at this point in the history
Issue 44 pipx location
  • Loading branch information
aj-white authored Sep 11, 2024
2 parents 92b4fab + 8c0611d commit 4b44ecf
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 66 deletions.
45 changes: 39 additions & 6 deletions src/piplexed/pipx_venvs.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
from __future__ import annotations

import json
import os
import platform
from dataclasses import dataclass
from pathlib import Path

from packaging.utils import NormalizedName
from packaging.utils import canonicalize_name
from packaging.version import Version
from platformdirs import user_data_path

DEFAULT_PIPX_HOME = Path.home() / ".local/pipx"
DEFAULT_PIPX_BIN_DIR = Path.home() / ".local/bin"
PIPX_HOME = Path(os.environ.get("PIPX_HOME", DEFAULT_PIPX_HOME)).resolve()
PIPX_LOCAL_VENVS = PIPX_HOME / "venvs"
OS_PLATFORM = platform.system()


def pipx_home_paths_for_os(platform_: str) -> tuple[Path, list[Path]]:
if platform_ == "Linux":
default_pipx_home = Path(user_data_path("pipx"))
fallback_pipx_homes = [Path.home() / ".local/pipx"]
elif platform_ == "Windows":
default_pipx_home = Path.home() / "pipx"
fallback_pipx_homes = [Path.home() / ".local/pipx", Path(user_data_path("pipx"))]
else:
default_pipx_home = Path.home() / ".local/pipx"
fallback_pipx_homes = [Path(user_data_path("pipx"))]

return (default_pipx_home, fallback_pipx_homes)


DEFAULT_PIPX_HOME, FALLBACK_PIPX_HOMES = pipx_home_paths_for_os(OS_PLATFORM)


def get_local_venv() -> Path | None:
if DEFAULT_PIPX_HOME.exists():
return DEFAULT_PIPX_HOME / "venvs"

for fallback_dir in FALLBACK_PIPX_HOMES:
if fallback_dir.exists():
return fallback_dir / "venvs"

return None


# PIPX_HOME = Path(os.environ.get("PIPX_HOME", DEFAULT_PIPX_HOME)).resolve()
PIPX_LOCAL_VENVS: Path | None = get_local_venv()


@dataclass
Expand All @@ -22,8 +52,11 @@ class PackageInfo:
python: str | None = None


def get_pipx_metadata(venv_dir: Path = PIPX_LOCAL_VENVS) -> list[PackageInfo]:
def get_pipx_metadata(venv_dir: Path | None = PIPX_LOCAL_VENVS) -> list[PackageInfo]:
venvs = []
if venv_dir is None or not venv_dir.exists():
msg = "Unable to find pipx venv installation location"
raise FileNotFoundError(msg)
for env in venv_dir.iterdir():
for item in env.iterdir():
if item.suffix == ".json": # pragma: no branch
Expand Down
5 changes: 3 additions & 2 deletions src/piplexed/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import warnings
from typing import Optional


def future_deprecation_warning(*, reason: str, replacement: str | None, deprecation_version: str) -> None:
def future_deprecation_warning(*, reason: str, replacement: Optional[str], deprecation_version: str) -> None: # noqa: UP007
"""Helper function to more cleanly inform users of upcoming deprecations
Parameters
Expand All @@ -21,4 +22,4 @@ def future_deprecation_warning(*, reason: str, replacement: str | None, deprecat

message = ". ".join(message_parts)

warnings.warn(message, stacklevel=2, category=FutureWarning)
warnings.warn(message.rstrip(), stacklevel=2, category=FutureWarning)
268 changes: 210 additions & 58 deletions tests/test_pipx_venvs.py
Original file line number Diff line number Diff line change
@@ -1,76 +1,228 @@
from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any
from unittest.mock import patch

import pytest
from packaging.version import Version

from piplexed.pipx_venvs import PackageInfo
from piplexed.pipx_venvs import get_local_venv
from piplexed.pipx_venvs import get_pipx_metadata
from piplexed.pipx_venvs import pipx_home_paths_for_os

TEST_PIPX_PYPI_METADATA = """{
"injected_packages": {},
"main_package": {
"app_paths": [
{
"__Path__": "C:\\\\path\\\\to\\\\test.exe",
"__type__": "Path"
}
],
"app_paths_of_dependencies": {},
"apps": [
"test.exe"
],
"apps_of_dependencies": [],
"include_apps": true,
"include_dependencies": false,
"package": "Testy_McTestFace",
"package_or_url": "Testy_McTestFace",
"package_version": "23.1.0",
"pip_args": [],
"suffix": ""
},
"pipx_metadata_version": "0.2",
"python_version": "Python 3.11.2",
"venv_args": []
}"""
PIPX_METADATA_VERSIONS = [None, "0.1", "0.2", "0.3", "0.4", "0.5"]

TEST_PIPX_LOCAL_METADATA = """{
# Metadata changes for 0.1 -> 0.3 and 0.4 -> 0.5 were at package level
MOCK_BASE_PIPX_METADATA: dict[str, Any] = {
"main_package": None,
"python_version": None,
"venv_args": [],
"injected_packages": {},
"main_package": {
"app_paths": [
{
"__Path__": "C:\\\\path\\\\to\\\\test.exe",
"__type__": "Path"
}
],
"app_paths_of_dependencies": {},
"apps": [
"local.exe"
],
"apps_of_dependencies": [],
"include_apps": true,
"include_dependencies": false,
"package": "Local_Package",
"package_or_url": "path/to/local/wheel",
"package_version": "23.1.0",
"pip_args": [],
"suffix": ""
"pipx_metadata_version": "0.1",
}

MOCK_PIPX_METADATA_0_4: dict[str, Any] = dict(MOCK_BASE_PIPX_METADATA, **{"source_interpreter": None})

MOCK_PACKAGE_DATA_0_1: dict[str, Any] = {
"package": None,
"package_or_url": None,
"pip_args": [],
"include_dependencies": False,
"include_apps": True,
"apps": [],
"app_paths": [],
"apps_of_dependencies": [],
"app_paths_of_dependencies": {},
"package_version": "",
}
# would like to use | operator e.g. # MOCK_PACKAGE_DATA_0_1 | {"suffix": ""} but not supported in python 3.8
MOCK_PACKAGE_DATA_0_2 = dict(MOCK_PACKAGE_DATA_0_1, **{"suffix": ""})

MOCK_PACKAGE_DATA_0_3_and_0_4 = dict(
MOCK_PACKAGE_DATA_0_2,
**{
"man_pages": [],
"man_paths": [],
"man_pages_of_dependencies": [],
"man_paths_of_dependencies": {},
},
"pipx_metadata_version": "0.3",
"python_version": "Python 3.11.2",
"venv_args": []
}"""
)

MOCK_PACKAGE_DATA_0_5 = dict(MOCK_PACKAGE_DATA_0_3_and_0_4, **{"pinned": False})


def mock_metadata(metadata_version: str, pypi_package: bool = True) -> dict[str, Any]: # noqa: FBT001, FBT002
if metadata_version in ["0.1", "0.2", "0.3"]:
metadata_template = MOCK_BASE_PIPX_METADATA
elif metadata_version in ["0.4", "0.5"]:
metadata_template = MOCK_PIPX_METADATA_0_4
else:
err_msg = f"Internal Test Error: Unknown metadata_version={metadata_version}"
raise Exception(err_msg)

if metadata_version == "0.1":
package_template = MOCK_PACKAGE_DATA_0_1
elif metadata_version == "0.2":
package_template = MOCK_PACKAGE_DATA_0_2
elif metadata_version in ["0.3", "0.4"]:
package_template = MOCK_PACKAGE_DATA_0_3_and_0_4
elif metadata_version == "0.5":
package_template = MOCK_PACKAGE_DATA_0_5
else:
err_msg = f"Internal Test Error: Unknown metadata_version={metadata_version}"
raise Exception(err_msg)

if pypi_package:
package_template["package"] = "Testy_McTestFace"
package_template["package_or_url"] = "Testy_McTestFace"
else:
package_template["package"] = "Local_Package"
package_template["package_or_url"] = "path/to/local/wheel"

package_template["package_version"] = "23.1.0"
metadata_template["pipx_metadata_version"] = metadata_version
metadata_template["python_version"] = "Python 3.11.2"
metadata_template["main_package"] = package_template

return metadata_template


def test_get_pipx_metadata(tmp_path):
@pytest.fixture
def venv_dir_test_setup(tmp_path):
env_dir = tmp_path / "venvs"
env_dir.mkdir()
pypi_package = env_dir / "pypi_package"
pypi_package.mkdir()
pypi_test_json = pypi_package / "test.json"
pypi_test_json.write_text(TEST_PIPX_PYPI_METADATA)

local_package = env_dir / "local_package"
local_package.mkdir()
local_test_json = local_package / "test.json"
local_test_json.write_text(TEST_PIPX_LOCAL_METADATA)

assert get_pipx_metadata(env_dir) == [
PackageInfo(name="testy-mctestface", version=Version("23.1.0"), python="3.11.2"),
return env_dir


@pytest.mark.parametrize("pipx_metadata_version", ("0.1", "0.2", "0.3", "0.4", "0.5"))
def test_pipx_metadata(venv_dir_test_setup, pipx_metadata_version):
pypi_json = venv_dir_test_setup / "pypi_package" / "test.json"
local_json = venv_dir_test_setup / "local_package" / "test.json"

expected = [PackageInfo(name="testy-mctestface", version=Version("23.1.0"), python="3.11.2")]

with open(pypi_json, "w") as f:
pipx_metadata = mock_metadata(metadata_version=pipx_metadata_version)
json.dump(pipx_metadata, f)

with open(local_json, "w") as f:
pipx_metadata = mock_metadata(metadata_version=pipx_metadata_version, pypi_package=False)
json.dump(pipx_metadata, f)

assert get_pipx_metadata(venv_dir_test_setup) == expected


def test_venv_dir_is_none():
with pytest.raises(FileNotFoundError) as execinfo:
get_pipx_metadata(venv_dir=None)

assert str(execinfo.value) == "Unable to find pipx venv installation location"


def test_venv_dir_not_exists(tmp_path):
non_existent_path = tmp_path / "joker"
with pytest.raises(FileNotFoundError) as execinfo:
get_pipx_metadata(venv_dir=non_existent_path)

assert str(execinfo.value) == "Unable to find pipx venv installation location"


@pytest.fixture
def mock_user_data_path():
with patch("piplexed.pipx_venvs.user_data_path") as mock_user_data:
mock_user_data.return_value = "mock/user/data/pipx"
yield mock_user_data


def test_pipx_home_paths_for_os_linux(mock_user_data_path): # noqa: ARG001
default, fallback = pipx_home_paths_for_os("Linux")
assert str(default) == os.path.join("mock", "user", "data", "pipx")
assert [str(p) for p in fallback] == [
os.path.join(str(Path.home()), ".local", "pipx"),
]


def test_pipx_home_paths_for_os_windows(mock_user_data_path): # noqa: ARG001
default, fallback = pipx_home_paths_for_os("Windows")
assert str(default) == os.path.join(str(Path.home()), "pipx")
assert [str(p) for p in fallback] == [
os.path.join(str(Path.home()), ".local", "pipx"),
os.path.join("mock", "user", "data", "pipx"),
]


def test_pipx_home_paths_for_os_other(mock_user_data_path): # noqa: ARG001
default, fallback = pipx_home_paths_for_os("Darwin")
assert str(default) == os.path.join(str(Path.home()), ".local", "pipx")
assert [str(p) for p in fallback] == [os.path.join("mock", "user", "data", "pipx")]


@pytest.fixture
def mock_pipx_homes(monkeypatch, tmp_path):
default_home = tmp_path / "default"
fallback_homes = [tmp_path / "fallback1", tmp_path / "fallback2"]

monkeypatch.setattr("piplexed.pipx_venvs.DEFAULT_PIPX_HOME", default_home)
monkeypatch.setattr("piplexed.pipx_venvs.FALLBACK_PIPX_HOMES", fallback_homes)

return (default_home, fallback_homes)


def test_default_pipx_home_exists(mock_pipx_homes):
default_home, _ = mock_pipx_homes
default_home.mkdir()

result = get_local_venv()
assert result == default_home / "venvs"


def test_default_pipx_home_not_exists_fallback1_exists(mock_pipx_homes):
_, fallbacks = mock_pipx_homes
fallback_1 = fallbacks[0]
fallback_1.mkdir()
result = get_local_venv()
assert result == fallback_1 / "venvs"


def test_default_pipx_home_not_exists_fallback2_exists(mock_pipx_homes):
_, fallbacks = mock_pipx_homes
fallback_2 = fallbacks[1]
fallback_2.mkdir()
result = get_local_venv()
assert result == fallback_2 / "venvs"


def test_default_pipx_home_not_exists_fallback1_and_fallback2_exists(mock_pipx_homes):
_, fallbacks = mock_pipx_homes
fallback_1 = fallbacks[0]
fallback_2 = fallbacks[1]
fallback_1.mkdir()
fallback_2.mkdir()
result = get_local_venv()
assert result == fallback_1 / "venvs"


def test_default_pipx_home_exists_and_fallback_exists(mock_pipx_homes):
default, fallbacks = mock_pipx_homes
fallback_1 = fallbacks[0]
fallback_2 = fallbacks[1]
default.mkdir()
fallback_1.mkdir()
fallback_2.mkdir()
result = get_local_venv()
assert result == default / "venvs"


def test_default_pipx_home_not_exists_and_fallback_not_exists(mock_pipx_homes):
default, fallbacks = mock_pipx_homes
result = get_local_venv()
assert result is None
Loading

0 comments on commit 4b44ecf

Please sign in to comment.