diff --git a/README.rst b/README.rst index 2f91d0f..68dc204 100644 --- a/README.rst +++ b/README.rst @@ -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. diff --git a/news/98.feature b/news/98.feature new file mode 100644 index 0000000..0e4251f --- /dev/null +++ b/news/98.feature @@ -0,0 +1 @@ +Support environments where pip is not installed. diff --git a/pyproject.toml b/pyproject.toml index 635fcc1..093ef31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/pip_deepfreeze/__main__.py b/src/pip_deepfreeze/__main__.py index 407bdab..536cd06 100644 --- a/src/pip_deepfreeze/__main__.py +++ b/src/pip_deepfreeze/__main__.py @@ -70,7 +70,7 @@ def sync( ), ), installer: Installer = typer.Option( - "default", + "pip", ), ) -> None: """Install/update the environment to match the project requirements. diff --git a/src/pip_deepfreeze/pip.py b/src/pip_deepfreeze/pip.py index 40a474d..c321345 100644 --- a/src/pip_deepfreeze/pip.py +++ b/src/pip_deepfreeze/pip.py @@ -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, @@ -49,8 +49,7 @@ class PipInspectReport(TypedDict, total=False): class Installer(str, Enum): - default = "default" - envpip = "env-pip" + pip = "pip" uv = "uv" @@ -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]]: @@ -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( @@ -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. @@ -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"])), ) @@ -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() @@ -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) diff --git a/src/pip_deepfreeze/sanity.py b/src/pip_deepfreeze/sanity.py index 101bbb4..a4bbdaf 100644 --- a/src/pip_deepfreeze/sanity.py +++ b/src/pip_deepfreeze/sanity.py @@ -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 @@ -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) @@ -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"] @@ -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}. " diff --git a/src/pip_deepfreeze/sync.py b/src/pip_deepfreeze/sync.py index eeadfcc..81b3cc2 100644 --- a/src/pip_deepfreeze/sync.py +++ b/src/pip_deepfreeze/sync.py @@ -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)