Skip to content

Commit c53ae29

Browse files
authored
Merge pull request #156 from sbidoul/refactor-installer-selection
Refactor installer selection, and use uv pip uninstall, uv pip freeze
2 parents d86d692 + ddd7b9d commit c53ae29

File tree

7 files changed

+268
-111
lines changed

7 files changed

+268
-111
lines changed

news/156.feature

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Use ``uv`` for freeze and uninstall operations too when ``uvpip`` has been selected.
2+
Don't do the direct url fixup optimization hack when ``uvpip`` has been selected.

src/pip_deepfreeze/__main__.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from packaging.utils import canonicalize_name
88
from packaging.version import Version
99

10-
from .pip import Installer
10+
from .pip import Installer, InstallerFlavor
1111
from .pyproject_toml import load_pyproject_toml
1212
from .sanity import check_env
1313
from .sync import sync as sync_operation
@@ -78,7 +78,7 @@ def sync(
7878
"Can be specified multiple times."
7979
),
8080
),
81-
installer: Installer = typer.Option(
81+
installer: InstallerFlavor = typer.Option(
8282
"pip",
8383
),
8484
) -> None:
@@ -91,6 +91,7 @@ def sync(
9191
constraints. Optionally uninstall unneeded dependencies.
9292
"""
9393
sync_operation(
94+
Installer.create(flavor=installer, python=ctx.obj.python),
9495
ctx.obj.python,
9596
upgrade_all,
9697
comma_split(to_upgrade),
@@ -99,7 +100,6 @@ def sync(
99100
project_root=ctx.obj.project_root,
100101
pre_sync_commands=pre_sync_commands,
101102
post_sync_commands=post_sync_commands,
102-
installer=installer,
103103
)
104104

105105

src/pip_deepfreeze/pip.py

+75-30
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import json
2-
import os
32
import shlex
43
import sys
54
import textwrap
5+
from abc import ABC, abstractmethod
66
from enum import Enum
77
from pathlib import Path
88
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, TypedDict, cast
@@ -52,38 +52,71 @@ class PipInspectReport(TypedDict, total=False):
5252
environment: Dict[str, str]
5353

5454

55-
class Installer(str, Enum):
55+
class InstallerFlavor(str, Enum):
5656
pip = "pip"
5757
uvpip = "uvpip"
5858

5959

60-
def _pip_install_cmd_and_env(python: str) -> Tuple[List[str], Dict[str, str]]:
61-
return [*get_pip_command(python), "install"], {}
60+
class Installer(ABC):
61+
@abstractmethod
62+
def install_cmd(self, python: str) -> List[str]: ...
6263

64+
@abstractmethod
65+
def uninstall_cmd(self, python: str) -> List[str]: ...
6366

64-
def _uv_pip_install_cmd_and_env(python: str) -> Tuple[List[str], Dict[str, str]]:
65-
return [sys.executable, "-m", "uv", "pip", "install", "--python", python], {}
67+
@abstractmethod
68+
def freeze_cmd(self, python: str) -> List[str]: ...
6669

70+
@abstractmethod
71+
def has_metadata_cache(self) -> bool:
72+
"""Whether the installer caches metadata preparation results."""
73+
...
6774

68-
def _install_cmd_and_env(
69-
installer: Installer, python: str
70-
) -> Tuple[List[str], Dict[str, str]]:
71-
if installer == Installer.pip:
72-
return _pip_install_cmd_and_env(python)
73-
elif installer == Installer.uvpip:
74-
if get_python_version_info(python) < (3, 7):
75-
log_error("The 'uv' installer requires Python 3.7 or later.")
76-
raise typer.Exit(1)
77-
return _uv_pip_install_cmd_and_env(python)
78-
raise NotImplementedError(f"Installer {installer} is not implemented.")
75+
@classmethod
76+
def create(cls, flavor: InstallerFlavor, python: str) -> "Installer":
77+
if flavor == InstallerFlavor.pip:
78+
return PipInstaller()
79+
elif flavor == InstallerFlavor.uvpip:
80+
if get_python_version_info(python) < (3, 7):
81+
log_error("The 'uv' installer requires Python 3.7 or later.")
82+
raise typer.Exit(1)
83+
return UvpipInstaller()
84+
85+
86+
class PipInstaller(Installer):
87+
def install_cmd(self, python: str) -> List[str]:
88+
return [*get_pip_command(python), "install"]
89+
90+
def uninstall_cmd(self, python: str) -> List[str]:
91+
return [*get_pip_command(python), "uninstall", "--yes"]
92+
93+
def freeze_cmd(self, python: str) -> List[str]:
94+
return [*get_pip_command(python), "freeze", "--all"]
95+
96+
def has_metadata_cache(self) -> bool:
97+
return False
98+
99+
100+
class UvpipInstaller(Installer):
101+
def install_cmd(self, python: str) -> List[str]:
102+
return [sys.executable, "-m", "uv", "pip", "install", "--python", python]
103+
104+
def uninstall_cmd(self, python: str) -> List[str]:
105+
return [sys.executable, "-m", "uv", "pip", "uninstall", "--python", python]
106+
107+
def freeze_cmd(self, python: str) -> List[str]:
108+
return [sys.executable, "-m", "uv", "pip", "freeze", "--python", python]
109+
110+
def has_metadata_cache(self) -> bool:
111+
return True
79112

80113

81114
def pip_upgrade_project(
115+
installer: Installer,
82116
python: str,
83117
constraints_filename: Path,
84118
project_root: Path,
85119
extras: Optional[Sequence[NormalizedName]] = None,
86-
installer: Installer = Installer.pip,
87120
installer_options: Optional[List[str]] = None,
88121
) -> None:
89122
"""Upgrade a project.
@@ -138,7 +171,9 @@ def pip_upgrade_project(
138171
# 2. get installed frozen dependencies of project
139172
installed_reqs = {
140173
get_req_name(req_line): normalize_req_line(req_line)
141-
for req_line in pip_freeze_dependencies(python, project_root, extras)[0]
174+
for req_line in pip_freeze_dependencies(
175+
installer, python, project_root, extras
176+
)[0]
142177
}
143178
assert all(installed_reqs.keys()) # XXX user error instead?
144179
# 3. uninstall dependencies that do not match constraints
@@ -152,11 +187,11 @@ def pip_upgrade_project(
152187
if to_uninstall:
153188
to_uninstall_str = ",".join(to_uninstall)
154189
log_info(f"Uninstalling dependencies to update: {to_uninstall_str}")
155-
pip_uninstall(python, to_uninstall)
190+
pip_uninstall(installer, python, to_uninstall)
156191
# 4. install project with constraints
157192
project_name = get_project_name(python, project_root)
158193
log_info(f"Installing/updating {project_name}")
159-
cmd, env = _install_cmd_and_env(installer, python)
194+
cmd = installer.install_cmd(python)
160195
if installer_options:
161196
cmd.extend(installer_options)
162197
cmd.extend(
@@ -181,7 +216,7 @@ def pip_upgrade_project(
181216
log_debug(textwrap.indent(constraints, prefix=" "))
182217
else:
183218
log_debug(f"with empty {constraints_without_editables_filename}.")
184-
check_call(cmd, env=dict(os.environ, **env))
219+
check_call(cmd)
185220

186221

187222
def _pip_list__env_info_json(python: str) -> InstalledDistributions:
@@ -222,14 +257,18 @@ def pip_list(python: str) -> InstalledDistributions:
222257
return _pip_list__env_info_json(python)
223258

224259

225-
def pip_freeze(python: str) -> Iterable[str]:
260+
def pip_freeze(installer: Installer, python: str) -> Iterable[str]:
226261
"""Run pip freeze."""
227-
cmd = [*get_pip_command(python), "freeze", "--all"]
262+
cmd = installer.freeze_cmd(python)
263+
log_debug(f"Running {shlex.join(cmd)}")
228264
return check_output(cmd).splitlines()
229265

230266

231267
def pip_freeze_dependencies(
232-
python: str, project_root: Path, extras: Optional[Sequence[NormalizedName]] = None
268+
installer: Installer,
269+
python: str,
270+
project_root: Path,
271+
extras: Optional[Sequence[NormalizedName]] = None,
233272
) -> Tuple[List[str], List[str]]:
234273
"""Run pip freeze, returning only dependencies of the project.
235274
@@ -241,7 +280,7 @@ def pip_freeze_dependencies(
241280
"""
242281
project_name = get_project_name(python, project_root)
243282
dependencies_names = list_installed_depends(pip_list(python), project_name, extras)
244-
frozen_reqs = pip_freeze(python)
283+
frozen_reqs = pip_freeze(installer, python)
245284
dependencies_reqs = []
246285
unneeded_reqs = []
247286
for frozen_req in frozen_reqs:
@@ -258,7 +297,10 @@ def pip_freeze_dependencies(
258297

259298

260299
def pip_freeze_dependencies_by_extra(
261-
python: str, project_root: Path, extras: Sequence[NormalizedName]
300+
installer: Installer,
301+
python: str,
302+
project_root: Path,
303+
extras: Sequence[NormalizedName],
262304
) -> Tuple[Dict[Optional[NormalizedName], List[str]], List[str]]:
263305
"""Run pip freeze, returning only dependencies of the project.
264306
@@ -272,7 +314,7 @@ def pip_freeze_dependencies_by_extra(
272314
dependencies_by_extras = list_installed_depends_by_extra(
273315
pip_list(python), project_name
274316
)
275-
frozen_reqs = pip_freeze(python)
317+
frozen_reqs = pip_freeze(installer, python)
276318
dependencies_reqs = {} # type: Dict[Optional[NormalizedName], List[str]]
277319
for extra in extras:
278320
if extra not in dependencies_by_extras:
@@ -301,12 +343,15 @@ def pip_freeze_dependencies_by_extra(
301343
return dependencies_reqs, unneeded_reqs
302344

303345

304-
def pip_uninstall(python: str, requirements: Iterable[str]) -> None:
346+
def pip_uninstall(
347+
installer: Installer, python: str, requirements: Iterable[str]
348+
) -> None:
305349
"""Uninstall packages."""
306350
reqs = list(requirements)
307351
if not reqs:
308352
return
309-
cmd = [*get_pip_command(python), "uninstall", "--yes", *reqs]
353+
cmd = [*installer.uninstall_cmd(python), *reqs]
354+
log_debug(f"Running {shlex.join(cmd)}")
310355
check_call(cmd)
311356

312357

src/pip_deepfreeze/sync.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def _constraints_path(project_root: Path) -> Path:
5353

5454

5555
def sync(
56+
installer: Installer,
5657
python: str,
5758
upgrade_all: bool,
5859
to_upgrade: List[str],
@@ -61,7 +62,6 @@ def sync(
6162
project_root: Path,
6263
pre_sync_commands: Sequence[str] = (),
6364
post_sync_commands: Sequence[str] = (),
64-
installer: Installer = Installer.pip,
6565
) -> None:
6666
# run pre-sync commands
6767
run_commands(pre_sync_commands, project_root, "pre-sync")
@@ -86,16 +86,16 @@ def sync(
8686
else:
8787
print(req_line.raw_line, file=constraints)
8888
pip_upgrade_project(
89+
installer,
8990
python,
9091
merged_constraints_path,
9192
project_root,
9293
extras=extras,
93-
installer=installer,
9494
installer_options=installer_options,
9595
)
9696
# freeze dependencies
9797
frozen_reqs_by_extra, unneeded_reqs = pip_freeze_dependencies_by_extra(
98-
python, project_root, extras
98+
installer, python, project_root, extras
9999
)
100100
for extra, frozen_reqs in frozen_reqs_by_extra.items():
101101
frozen_requirements_path = make_frozen_requirements_path(project_root, extra)
@@ -141,14 +141,15 @@ def sync(
141141
prompted = True
142142
if uninstall_unneeded:
143143
log_info(f"Uninstalling unneeded distributions: {unneeded_reqs_str}")
144-
pip_uninstall(python, unneeded_req_names)
144+
pip_uninstall(installer, python, unneeded_req_names)
145145
elif not prompted:
146146
log_debug(
147147
f"The following distributions "
148148
f"that are not dependencies of {project_name_with_extras} "
149149
f"are also installed: {unneeded_reqs_str}"
150150
)
151151
# fixup VCS direct_url.json (see fixup-vcs-direct-urls.py for details on why)
152-
pip_fixup_vcs_direct_urls(python)
152+
if not installer.has_metadata_cache():
153+
pip_fixup_vcs_direct_urls(python)
153154
# run post-sync commands
154155
run_commands(post_sync_commands, project_root, "post-sync")

tests/test_fixup_direct_url.py

+20-14
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,58 @@
11
import subprocess
22

3-
from pip_deepfreeze.pip import pip_fixup_vcs_direct_urls, pip_freeze
3+
import pytest
4+
5+
from pip_deepfreeze.pip import (
6+
Installer,
7+
PipInstaller,
8+
UvpipInstaller,
9+
pip_fixup_vcs_direct_urls,
10+
pip_freeze,
11+
)
412

513

614
def test_fixup_vcs_direct_url_branch_fake_commit(virtualenv_python: str) -> None:
715
"""When installing from a git branch, the commit_id in direct_url.json is replaced
816
with a fake one.
917
"""
18+
installer = PipInstaller()
1019
subprocess.check_call(
1120
[
12-
virtualenv_python,
13-
"-m",
14-
"pip",
15-
"install",
21+
*installer.install_cmd(virtualenv_python),
1622
"git+https://github.com/PyPA/pip-test-package",
1723
]
1824
)
19-
frozen = "\n".join(pip_freeze(virtualenv_python))
25+
frozen = "\n".join(pip_freeze(installer, virtualenv_python))
2026
assert "git+https://github.com/PyPA/pip-test-package" in frozen
2127
# fake commit NOT in direct_url.json
2228
assert f"git+https://github.com/PyPA/pip-test-package@{'f'*40}" not in frozen
2329
pip_fixup_vcs_direct_urls(virtualenv_python)
24-
frozen = "\n".join(pip_freeze(virtualenv_python))
30+
frozen = "\n".join(pip_freeze(installer, virtualenv_python))
2531
# fake commit in direct_url.json
2632
assert f"git+https://github.com/PyPA/pip-test-package@{'f'*40}" in frozen
2733

2834

29-
def test_fixup_vcs_direct_url_commit(virtualenv_python: str) -> None:
35+
@pytest.mark.parametrize("installer", [PipInstaller(), UvpipInstaller()])
36+
def test_fixup_vcs_direct_url_commit(
37+
installer: Installer, virtualenv_python: str
38+
) -> None:
3039
"""When installing from a git commit, the commit_id in direct_url.json is left
3140
untouched.
3241
"""
3342
subprocess.check_call(
3443
[
35-
virtualenv_python,
36-
"-m",
37-
"pip",
38-
"install",
44+
*installer.install_cmd(virtualenv_python),
3945
"git+https://github.com/PyPA/pip-test-package"
4046
"@5547fa909e83df8bd743d3978d6667497983a4b7",
4147
]
4248
)
43-
frozen = "\n".join(pip_freeze(virtualenv_python))
49+
frozen = "\n".join(pip_freeze(installer, virtualenv_python))
4450
assert (
4551
"git+https://github.com/PyPA/pip-test-package"
4652
"@5547fa909e83df8bd743d3978d6667497983a4b7" in frozen
4753
)
4854
pip_fixup_vcs_direct_urls(virtualenv_python)
49-
frozen = "\n".join(pip_freeze(virtualenv_python))
55+
frozen = "\n".join(pip_freeze(installer, virtualenv_python))
5056
assert (
5157
"git+https://github.com/PyPA/pip-test-package"
5258
"@5547fa909e83df8bd743d3978d6667497983a4b7" in frozen

0 commit comments

Comments
 (0)