Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 1 addition & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,7 @@ pip-df sync
dependencies of the project. If not
specified, ask confirmation.

--installer [default|env-pip|uv]
The installer to use. For now the default is
to use 'pip' that is installed in the target
environment. To use 'uv', pip-deepfreeze must be
installed with the 'uv' extra.
--installer [pip|uv]

--help Show this message and exit.

Expand Down
1 change: 1 addition & 0 deletions news/98.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support environments where pip is not installed.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ requires-python = ">=3.8"
dependencies=[
"httpx",
"packaging>=23",
"pip>=22.2",
"tomli ; python_version<'3.11'",
"typer[all]>=0.3.2",
]
Expand Down
2 changes: 1 addition & 1 deletion src/pip_deepfreeze/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def sync(
),
),
installer: Installer = typer.Option(
"default",
"pip",
),
) -> None:
"""Install/update the environment to match the project requirements.
Expand Down
23 changes: 10 additions & 13 deletions src/pip_deepfreeze/pip.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
parse as parse_req_file,
)
from .req_parser import get_req_name
from .sanity import _get_env_info, get_pip_version
from .sanity import _get_env_info, get_pip_command, get_pip_version
from .utils import (
check_call,
check_output,
Expand All @@ -49,8 +49,7 @@ class PipInspectReport(TypedDict, total=False):


class Installer(str, Enum):
default = "default"
envpip = "env-pip"
pip = "pip"
uv = "uv"


Expand All @@ -59,8 +58,8 @@ def _has_uv() -> bool:
return bool(importlib.util.find_spec("uv"))


def _env_pip_install_cmd_and_env(python: str) -> Tuple[List[str], Dict[str, str]]:
return [python, "-m", "pip", "install"], {}
def _pip_install_cmd_and_env(python: str) -> Tuple[List[str], Dict[str, str]]:
return [*get_pip_command(python), "install"], {}


def _uv_install_cmd_and_env(python: str) -> Tuple[List[str], Dict[str, str]]:
Expand All @@ -74,10 +73,8 @@ def _uv_install_cmd_and_env(python: str) -> Tuple[List[str], Dict[str, str]]:
def _install_cmd_and_env(
installer: Installer, python: str
) -> Tuple[List[str], Dict[str, str]]:
if installer == Installer.default:
installer = Installer.envpip
if installer == Installer.envpip:
return _env_pip_install_cmd_and_env(python)
if installer == Installer.pip:
return _pip_install_cmd_and_env(python)
elif installer == Installer.uv:
if not _has_uv():
log_error(
Expand All @@ -94,7 +91,7 @@ def pip_upgrade_project(
constraints_filename: Path,
project_root: Path,
extras: Optional[Sequence[NormalizedName]] = None,
installer: Installer = Installer.envpip,
installer: Installer = Installer.pip,
) -> None:
"""Upgrade a project.

Expand Down Expand Up @@ -202,7 +199,7 @@ def _pip_list__env_info_json(python: str) -> InstalledDistributions:
def _pip_inspect(python: str) -> PipInspectReport:
return cast(
PipInspectReport,
json.loads(check_output([python, "-m", "pip", "--quiet", "inspect"])),
json.loads(check_output([*get_pip_command(python), "--quiet", "inspect"])),
)


Expand Down Expand Up @@ -230,7 +227,7 @@ def pip_list(python: str) -> InstalledDistributions:

def pip_freeze(python: str) -> Iterable[str]:
"""Run pip freeze."""
cmd = [python, "-m", "pip", "freeze", "--all"]
cmd = [*get_pip_command(python), "freeze", "--all"]
return check_output(cmd).splitlines()


Expand Down Expand Up @@ -312,7 +309,7 @@ def pip_uninstall(python: str, requirements: Iterable[str]) -> None:
reqs = list(requirements)
if not reqs:
return
cmd = [python, "-m", "pip", "uninstall", "--yes", *reqs]
cmd = [*get_pip_command(python), "uninstall", "--yes", *reqs]
check_call(cmd)


Expand Down
59 changes: 50 additions & 9 deletions src/pip_deepfreeze/sanity.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import json
import shlex
import subprocess
import sys
from functools import lru_cache
from importlib.metadata import version
from importlib.resources import path as resource_path
from typing import Optional, Tuple, TypedDict, cast

import typer
from packaging.version import Version

from .utils import log_error, log_warning
Expand Down Expand Up @@ -34,19 +37,43 @@ def _get_env_info(python: str) -> EnvInfo:
return cast(EnvInfo, json.loads(env_info_json))


@lru_cache
def get_pip_version(python: str) -> Version:
pip_version = _get_env_info(python).get("pip_version")
# assert because we have checked the pip availability before
assert pip_version, "pip is not available"
return Version(pip_version)
if pip_version:
return Version(pip_version)
return Version(version("pip"))


@lru_cache
def get_pip_command(python: str) -> Tuple[str, ...]:
env_pip_version = _get_env_info(python).get("pip_version")
if env_pip_version:
# pip is installed in the target environment, let's use it
return (python, "-m", "pip")
if not local_pip_compatible(python):
log_error(
f"pip is not available to {python}, and the pip version. "
f"installed the pip-deepfreeze environment is not compatible with it. "
f"Please install pip in the target environment."
)
raise typer.Exit(1)
return (sys.executable, "-m", "pip", "--python", python)


@lru_cache
def get_python_version_info(python: str) -> Tuple[int, ...]:
python_version = _get_env_info(python).get("python_version")
assert python_version
return tuple(map(int, python_version.split(".", 1)))


@lru_cache
def local_pip_compatible(python: str) -> bool:
cmd = [sys.executable, "-m", "pip", "--python", python, "--version"]
return subprocess.call(cmd) == 0


def check_env(python: str) -> bool:
_get_env_info.cache_clear()
env_info = _get_env_info(python)
Expand All @@ -65,9 +92,19 @@ def check_env(python: str) -> bool:
return False
pip_version = env_info.get("pip_version")
if not env_info.get("has_pkg_resources") and (
# pip_version is None if pkg_resources is not installed for python<3.8
not pip_version
or Version(pip_version) < Version("22.2")
(
# target pip does not have pip inspect: we need pkg_resources to
# inspect with env-info-json.py
pip_version
and Version(pip_version) < Version("22.2")
)
or (
# pip not installed in target python env and local pip is not compatible
# with target python, so we'll need pkg_resources to inspect with
# env-info-json.py
not pip_version
and not local_pip_compatible(python)
)
):
setuptools_install_cmd = shlex.join(
[python, "-m", "pip", "install", "setuptools"]
Expand All @@ -84,17 +121,21 @@ def check_env(python: str) -> bool:
return False
# Testing for pip must be done after testing for pkg_resources, because
# pkg_resources is needed to obtain the pip version for python < 3.8.
if not pip_version:
if not pip_version and not local_pip_compatible(python):
log_error(f"pip is not available to {python}. Please install it.")
return False
if Version(pip_version) < Version("20.1"):
if pip_version and Version(pip_version) < Version("20.1"):
pip_install_cmd = shlex.join([python, "-m", "pip", "install", "pip>=20.1"])
log_warning(
f"pip-deepfreeze works best with pip>=20.1, "
f"in particular if you use direct URL references. "
f"You can upgrade pip it with '{pip_install_cmd}'."
)
if not env_info.get("wheel_version") and Version(pip_version) < Version("23.1"):
if (
not env_info.get("wheel_version")
and pip_version
and Version(pip_version) < Version("23.1")
):
wheel_install_cmd = shlex.join([python, "-m", "pip", "install", "wheel"])
log_warning(
f"wheel is not available to {python}. "
Expand Down
2 changes: 1 addition & 1 deletion src/pip_deepfreeze/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def sync(
uninstall_unneeded: Optional[bool],
project_root: Path,
post_sync_commands: Sequence[str] = (),
installer: Installer = Installer.envpip,
installer: Installer = Installer.pip,
) -> None:
project_name = get_project_name(python, project_root)
project_name_with_extras = make_project_name_with_extras(project_name, extras)
Expand Down
7 changes: 7 additions & 0 deletions tests/test_sanity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
import sys
from pathlib import Path

import pytest
from typer.testing import CliRunner

from pip_deepfreeze.__main__ import app
from pip_deepfreeze.sanity import check_env, get_python_version_info


@pytest.mark.xfail(
reason=(
"check_env succeeds because the pip inversion installed with pip-deepfreeze "
"is compatible with the target environment."
)
)
def test_sanity_pip(virtualenv_python, capsys):
assert check_env(virtualenv_python)
subprocess.check_call([virtualenv_python, "-m", "pip", "uninstall", "-y", "pip"])
Expand Down