Repository: sbidoul/pip-deepfreeze (language: python)

Issue:
Support environments where pip is not installed
This could be done by providing `pip-deepfreeze` with a target pip command in addition to a target python interpreter.
That pip command could then be `target-pip --python {target-python} ...`.


Discussion / hints:
Or, we could install pip as a dependency of pip-deepfreeze, and use `{sys.executable} -m pip --python {python} install`.

We need to check that the pip version we have is compatible with the target python, though.

Fix patch:
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,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)


Test patch:
diff --git a/tests/test_sanity.py b/tests/test_sanity.py
index 76f3f6d..417136d 100644
--- a/tests/test_sanity.py
+++ b/tests/test_sanity.py
@@ -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"])