Skip to content

Commit d083f87

Browse files
committed
Lock command PoC
1 parent 21da596 commit d083f87

File tree

3 files changed

+196
-0
lines changed

3 files changed

+196
-0
lines changed

src/pip_deepfreeze/__main__.py

+54
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import typer
77
from packaging.utils import canonicalize_name
88

9+
from .lock import lock as lock_operation
910
from .pyproject_toml import load_pyproject_toml
1011
from .sanity import check_env
1112
from .sync import sync as sync_operation
@@ -88,6 +89,59 @@ def sync(
8889
)
8990

9091

92+
@app.command()
93+
def lock(
94+
ctx: typer.Context,
95+
to_upgrade: str = typer.Option(
96+
None,
97+
"--update",
98+
"-u",
99+
metavar="DEP1,DEP2,...",
100+
help=(
101+
"Make sure selected dependencies are upgraded (or downgraded) to "
102+
"the latest allowed version. If DEP is not part of your application "
103+
"dependencies anymore, this option has no effect."
104+
),
105+
),
106+
upgrade_all: bool = typer.Option(
107+
False,
108+
"--update-all",
109+
help=(
110+
"Upgrade (or downgrade) all dependencies of your application to "
111+
"the latest allowed version."
112+
),
113+
show_default=False,
114+
),
115+
extras: str = typer.Option(
116+
None,
117+
"--extras",
118+
"-x",
119+
metavar="EXTRAS",
120+
help=(
121+
"Comma separated list of extras "
122+
"to install and freeze to requirements-{EXTRA}.txt."
123+
),
124+
),
125+
post_lock_commands: List[str] = typer.Option(
126+
[],
127+
"--post-lock-command",
128+
help=(
129+
"Command to run after the lock operation is complete. "
130+
"Can be specified multiple times."
131+
),
132+
),
133+
) -> None:
134+
"""Generate/update lock files without modifying the environment."""
135+
lock_operation(
136+
ctx.obj.python,
137+
upgrade_all,
138+
comma_split(to_upgrade),
139+
extras=[canonicalize_name(extra) for extra in comma_split(extras)],
140+
project_root=ctx.obj.project_root,
141+
post_lock_commands=post_lock_commands,
142+
)
143+
144+
91145
@app.command()
92146
def tree(
93147
ctx: typer.Context,

src/pip_deepfreeze/lock.py

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from pathlib import Path
2+
from typing import List, Sequence
3+
4+
from packaging.utils import NormalizedName
5+
6+
from .installed_dist import installed_distributions_as_pip_requirements
7+
from .pip import (
8+
_dependencies_by_extra, # TODO _dependencies_by_extra should be elsewhere
9+
pip_dry_run_install_project,
10+
)
11+
from .project_name import get_project_name
12+
from .req_file_parser import OptionsLine, parse as parse_req_file
13+
from .req_merge import prepare_frozen_reqs_for_upgrade
14+
from .utils import (
15+
HttpFetcher,
16+
get_temp_path_in_dir,
17+
make_requirements_path,
18+
make_requirements_paths,
19+
open_with_rollback,
20+
run_commands,
21+
)
22+
23+
24+
def lock(
25+
python: str,
26+
upgrade_all: bool,
27+
to_upgrade: List[str],
28+
extras: List[NormalizedName],
29+
project_root: Path,
30+
post_lock_commands: Sequence[str] = (),
31+
) -> None:
32+
# TODO error out if pip does not support install report
33+
project_name = get_project_name(python, project_root)
34+
requirements_in = project_root / "requirements.txt.in"
35+
# upgrade project and its dependencies, if needed
36+
constraints_path = get_temp_path_in_dir(
37+
dir=project_root, prefix="requirements.", suffix=".txt.df"
38+
)
39+
with constraints_path.open(mode="w", encoding="utf-8") as constraints:
40+
for req_line in prepare_frozen_reqs_for_upgrade(
41+
make_requirements_paths(project_root, extras),
42+
requirements_in,
43+
upgrade_all,
44+
to_upgrade,
45+
):
46+
print(req_line, file=constraints)
47+
installed_distributions = pip_dry_run_install_project(
48+
python,
49+
constraints_path,
50+
project_root,
51+
extras=extras,
52+
)
53+
# freeze dependencies
54+
# TODO _dependencies_by_extra should be elsewhere
55+
frozen_reqs_by_extra, unneeded_reqs = _dependencies_by_extra(
56+
project_name,
57+
extras,
58+
installed_distributions,
59+
installed_distributions_as_pip_requirements(installed_distributions),
60+
)
61+
assert not unneeded_reqs
62+
for extra, frozen_reqs in frozen_reqs_by_extra.items():
63+
requirements_frozen_path = make_requirements_path(project_root, extra)
64+
with open_with_rollback(requirements_frozen_path) as f:
65+
print("# frozen requirements generated by pip-deepfreeze", file=f)
66+
# output pip options in main requirements only
67+
if not extra and requirements_in.exists():
68+
# XXX can we avoid this second parse of requirements.txt.in?
69+
for parsed_req_line in parse_req_file(
70+
str(requirements_in),
71+
reqs_only=False,
72+
recurse=True,
73+
strict=True,
74+
http_fetcher=HttpFetcher(),
75+
):
76+
if isinstance(parsed_req_line, OptionsLine):
77+
print(parsed_req_line.raw_line, file=f)
78+
# output frozen dependencies of project
79+
for req_line in frozen_reqs:
80+
print(req_line, file=f)
81+
# run post-lock commands
82+
run_commands(post_lock_commands, project_root, "post-lock")

src/pip_deepfreeze/pip.py

+60
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import shlex
3+
import tempfile
34
from importlib.resources import path as resource_path
45
from pathlib import Path
56
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, TypedDict, cast
@@ -11,6 +12,7 @@
1112
EnvInfoInstalledDistribution,
1213
InstalledDistributions,
1314
PipInspectInstalledDistribution,
15+
PipInstallReportItemInstalledDistribution,
1416
)
1517
from .list_installed_depends import (
1618
list_installed_depends,
@@ -41,6 +43,64 @@ class PipInspectReport(TypedDict, total=False):
4143
environment: Dict[str, str]
4244

4345

46+
class PipInstallReport(TypedDict, total=False):
47+
version: str
48+
install: List[Dict[str, Any]]
49+
environment: Dict[str, str]
50+
51+
52+
def _install_report_to_installed_distributions(
53+
report: PipInstallReport,
54+
) -> InstalledDistributions:
55+
environment = report["environment"]
56+
dists = [
57+
PipInstallReportItemInstalledDistribution(json_dist, environment)
58+
for json_dist in report["install"]
59+
]
60+
return {dist.name: dist for dist in dists}
61+
62+
63+
def pip_dry_run_install_project(
64+
python: str,
65+
constraints_filename: Path,
66+
project_root: Path,
67+
extras: Optional[Sequence[NormalizedName]] = None,
68+
) -> InstalledDistributions:
69+
# dry-run install project with constraints
70+
with tempfile.NamedTemporaryFile() as report:
71+
report.close()
72+
project_name = get_project_name(python, project_root)
73+
log_info(f"Dry-run installing {project_name}")
74+
cmd = [
75+
python,
76+
"-m",
77+
"pip",
78+
"install",
79+
"--dry-run",
80+
"--ignore-installed",
81+
"--report",
82+
report.name,
83+
"-c",
84+
f"{constraints_filename}",
85+
]
86+
cmd.append("-e")
87+
if extras:
88+
extras_str = ",".join(extras)
89+
cmd.append(f"{project_root}[{extras_str}]")
90+
else:
91+
cmd.append(f"{project_root}")
92+
log_debug(f"Running {shlex.join(cmd)}")
93+
constraints = constraints_filename.read_text(encoding="utf-8").strip()
94+
if constraints:
95+
log_debug(f"with {constraints_filename}:")
96+
log_debug(constraints)
97+
else:
98+
log_debug(f"with empty {constraints_filename}.")
99+
check_call(cmd)
100+
with open(report.name, encoding="utf-8") as json_file:
101+
return _install_report_to_installed_distributions(json.load(json_file))
102+
103+
44104
def pip_upgrade_project(
45105
python: str,
46106
constraints_filename: Path,

0 commit comments

Comments
 (0)