Skip to content

Commit 4072aff

Browse files
committed
Add pip lock command (PEP 751)
1 parent 8c48ffb commit 4072aff

File tree

5 files changed

+333
-4
lines changed

5 files changed

+333
-4
lines changed

src/pip/_internal/commands/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
"InstallCommand",
2424
"Install packages.",
2525
),
26+
"lock": CommandInfo(
27+
"pip._internal.commands.lock",
28+
"LockCommand",
29+
"Generate a lock file.",
30+
),
2631
"download": CommandInfo(
2732
"pip._internal.commands.download",
2833
"DownloadCommand",

src/pip/_internal/commands/lock.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import sys
2+
from optparse import Values
3+
from typing import List
4+
5+
from pip._internal.cache import WheelCache
6+
from pip._internal.cli import cmdoptions
7+
from pip._internal.cli.req_command import (
8+
RequirementCommand,
9+
with_cleanup,
10+
)
11+
from pip._internal.cli.status_codes import SUCCESS
12+
from pip._internal.models.pylock import Pylock
13+
from pip._internal.operations.build.build_tracker import get_build_tracker
14+
from pip._internal.req.req_install import (
15+
check_legacy_setup_py_options,
16+
)
17+
from pip._internal.utils.logging import getLogger
18+
from pip._internal.utils.misc import (
19+
get_pip_version,
20+
)
21+
from pip._internal.utils.temp_dir import TempDirectory
22+
23+
logger = getLogger(__name__)
24+
25+
26+
class LockCommand(RequirementCommand):
27+
"""
28+
Lock packages from:
29+
30+
- PyPI (and other indexes) using requirement specifiers.
31+
- VCS project urls.
32+
- Local project directories.
33+
- Local or remote source archives.
34+
35+
pip also supports locking from "requirements files", which provide
36+
an easy way to specify a whole environment to be installed.
37+
"""
38+
39+
usage = """
40+
%prog [options] <requirement specifier> [package-index-options] ...
41+
%prog [options] -r <requirements file> [package-index-options] ...
42+
%prog [options] [-e] <vcs project url> ...
43+
%prog [options] [-e] <local project path> ...
44+
%prog [options] <archive url/path> ..."""
45+
46+
def add_options(self) -> None:
47+
self.cmd_opts.add_option(cmdoptions.requirements())
48+
self.cmd_opts.add_option(cmdoptions.constraints())
49+
self.cmd_opts.add_option(cmdoptions.no_deps())
50+
self.cmd_opts.add_option(cmdoptions.pre())
51+
52+
self.cmd_opts.add_option(cmdoptions.editable())
53+
54+
self.cmd_opts.add_option(cmdoptions.src())
55+
56+
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
57+
self.cmd_opts.add_option(cmdoptions.no_build_isolation())
58+
self.cmd_opts.add_option(cmdoptions.use_pep517())
59+
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
60+
self.cmd_opts.add_option(cmdoptions.check_build_deps())
61+
62+
self.cmd_opts.add_option(cmdoptions.config_settings())
63+
64+
self.cmd_opts.add_option(cmdoptions.no_binary())
65+
self.cmd_opts.add_option(cmdoptions.only_binary())
66+
self.cmd_opts.add_option(cmdoptions.prefer_binary())
67+
self.cmd_opts.add_option(cmdoptions.require_hashes())
68+
self.cmd_opts.add_option(cmdoptions.progress_bar())
69+
70+
index_opts = cmdoptions.make_option_group(
71+
cmdoptions.index_group,
72+
self.parser,
73+
)
74+
75+
self.parser.insert_option_group(0, index_opts)
76+
self.parser.insert_option_group(0, self.cmd_opts)
77+
78+
@with_cleanup
79+
def run(self, options: Values, args: List[str]) -> int:
80+
logger.verbose("Using %s", get_pip_version())
81+
82+
session = self.get_default_session(options)
83+
84+
finder = self._build_package_finder(
85+
options=options,
86+
session=session,
87+
ignore_requires_python=options.ignore_requires_python,
88+
)
89+
build_tracker = self.enter_context(get_build_tracker())
90+
91+
directory = TempDirectory(
92+
delete=not options.no_clean,
93+
kind="install",
94+
globally_managed=True,
95+
)
96+
97+
reqs = self.get_requirements(args, options, finder, session)
98+
check_legacy_setup_py_options(options, reqs)
99+
100+
wheel_cache = WheelCache(options.cache_dir)
101+
102+
# Only when installing is it permitted to use PEP 660.
103+
# In other circumstances (pip wheel, pip download) we generate
104+
# regular (i.e. non editable) metadata and wheels.
105+
for req in reqs:
106+
req.permit_editable_wheels = True
107+
108+
preparer = self.make_requirement_preparer(
109+
temp_build_dir=directory,
110+
options=options,
111+
build_tracker=build_tracker,
112+
session=session,
113+
finder=finder,
114+
use_user_site=False,
115+
verbosity=self.verbosity,
116+
)
117+
resolver = self.make_resolver(
118+
preparer=preparer,
119+
finder=finder,
120+
options=options,
121+
wheel_cache=wheel_cache,
122+
use_user_site=False,
123+
ignore_installed=True,
124+
ignore_requires_python=options.ignore_requires_python,
125+
upgrade_strategy="to-satisfy-only",
126+
use_pep517=options.use_pep517,
127+
)
128+
129+
self.trace_basic_info(finder)
130+
131+
requirement_set = resolver.resolve(reqs, check_supported_wheels=True)
132+
133+
pyproject_lock = Pylock.from_install_requirements(
134+
requirement_set.requirements.values()
135+
)
136+
sys.stdout.write(pyproject_lock.as_toml())
137+
138+
return SUCCESS

src/pip/_internal/models/pylock.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import dataclasses
2+
from dataclasses import dataclass
3+
from typing import Any, Dict, Iterable, List, Optional, Tuple
4+
5+
from pip._vendor import tomli_w
6+
from pip._vendor.typing_extensions import Self
7+
8+
from pip._internal.models.direct_url import ArchiveInfo, DirInfo, VcsInfo
9+
from pip._internal.models.link import Link
10+
from pip._internal.req.req_install import InstallRequirement
11+
from pip._internal.utils.urls import url_to_path
12+
13+
14+
def _toml_dict_factory(data: List[Tuple[str, Any]]) -> Dict[str, Any]:
15+
return {key.replace("_", "-"): value for key, value in data if value is not None}
16+
17+
18+
@dataclass
19+
class PackageVcs:
20+
type: str
21+
url: Optional[str]
22+
# (not supported) path: Optional[str]
23+
requested_revision: Optional[str]
24+
commit_id: str
25+
subdirectory: Optional[str]
26+
27+
28+
@dataclass
29+
class PackageDirectory:
30+
path: str
31+
editable: Optional[bool]
32+
subdirectory: Optional[str]
33+
34+
35+
@dataclass
36+
class PackageArchive:
37+
url: Optional[str]
38+
# (not supported) path: Optional[str]
39+
# (not supported) size: Optional[int]
40+
hashes: Dict[str, str]
41+
subdirectory: Optional[str]
42+
43+
44+
@dataclass
45+
class PackageSdist:
46+
name: str
47+
# (not supported) upload_time: Optional[datetime]
48+
url: Optional[str]
49+
# (not supported) path: Optional[str]
50+
# (not supported) size: Optional[int]
51+
hashes: Dict[str, str]
52+
53+
54+
@dataclass
55+
class PackageWheel:
56+
name: str
57+
# (not supported) upload_time: Optional[datetime]
58+
url: Optional[str]
59+
# (not supported) path: Optional[str]
60+
# (not supported) size: Optional[int]
61+
hashes: Dict[str, str]
62+
63+
64+
@dataclass
65+
class Package:
66+
name: str
67+
version: Optional[str] = None
68+
# (not supported) marker: Optional[str]
69+
# (not supported) requires_python: Optional[str]
70+
# (not supported) dependencies
71+
direct: Optional[bool] = None
72+
vcs: Optional[PackageVcs] = None
73+
directory: Optional[PackageDirectory] = None
74+
archive: Optional[PackageArchive] = None
75+
# (not supported) index: Optional[str]
76+
sdist: Optional[PackageSdist] = None
77+
wheels: Optional[List[PackageWheel]] = None
78+
# (not supported) attestation_identities: Optional[List[Dict[str, Any]]]
79+
# (not supported) tool: Optional[Dict[str, Any]]
80+
81+
@classmethod
82+
def from_install_requirement(cls, ireq: InstallRequirement) -> Self:
83+
dist = ireq.get_dist()
84+
download_info = ireq.download_info
85+
assert download_info
86+
package = cls(
87+
name=dist.canonical_name,
88+
version=str(dist.version),
89+
)
90+
package.direct = ireq.is_direct if ireq.is_direct else None
91+
if package.direct:
92+
if isinstance(download_info.info, VcsInfo):
93+
package.vcs = PackageVcs(
94+
type=download_info.info.vcs,
95+
url=download_info.url,
96+
requested_revision=download_info.info.requested_revision,
97+
commit_id=download_info.info.commit_id,
98+
subdirectory=download_info.subdirectory,
99+
)
100+
elif isinstance(download_info.info, DirInfo):
101+
package.directory = PackageDirectory(
102+
path=url_to_path(download_info.url),
103+
editable=(
104+
download_info.info.editable
105+
if download_info.info.editable
106+
else None
107+
),
108+
subdirectory=download_info.subdirectory,
109+
)
110+
elif isinstance(download_info.info, ArchiveInfo):
111+
if not download_info.info.hashes:
112+
raise NotImplementedError()
113+
package.archive = PackageArchive(
114+
url=download_info.url,
115+
hashes=download_info.info.hashes,
116+
subdirectory=download_info.subdirectory,
117+
)
118+
else:
119+
# should never happen
120+
raise NotImplementedError()
121+
else:
122+
if isinstance(download_info.info, ArchiveInfo):
123+
if not download_info.info.hashes:
124+
raise NotImplementedError()
125+
link = Link(download_info.url)
126+
if link.is_wheel:
127+
package.wheels = [
128+
PackageWheel(
129+
name=link.filename,
130+
url=download_info.url,
131+
hashes=download_info.info.hashes,
132+
)
133+
]
134+
else:
135+
package.sdist = PackageSdist(
136+
name=link.filename,
137+
url=download_info.url,
138+
hashes=download_info.info.hashes,
139+
)
140+
else:
141+
# should never happen
142+
raise NotImplementedError()
143+
return package
144+
145+
146+
@dataclass
147+
class Pylock:
148+
lock_version: str = "1.0"
149+
# (not supported) environments: Optional[List[str]]
150+
# (not supported) requires_python: Optional[str]
151+
created_by: str = "pip"
152+
packages: List[Package] = dataclasses.field(default_factory=list)
153+
# (not supported) tool: Optional[Dict[str, Any]]
154+
155+
def as_toml(self) -> str:
156+
return tomli_w.dumps(dataclasses.asdict(self, dict_factory=_toml_dict_factory))
157+
158+
@classmethod
159+
def from_install_requirements(
160+
cls, install_requirements: Iterable[InstallRequirement]
161+
) -> Self:
162+
return cls(
163+
packages=sorted(
164+
(
165+
Package.from_install_requirement(ireq)
166+
for ireq in install_requirements
167+
),
168+
key=lambda p: p.name,
169+
)
170+
)

tests/functional/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_entrypoints_work(entrypoint: str, script: PipTestEnvironment) -> None:
5757
sorted(
5858
set(commands_dict).symmetric_difference(
5959
# Exclude commands that are expected to use the network.
60-
{"install", "download", "search", "index", "wheel"}
60+
{"install", "download", "search", "index", "lock", "wheel"}
6161
)
6262
),
6363
)

tests/unit/test_commands.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414

1515
# These are the expected names of the commands whose classes inherit from
1616
# IndexGroupCommand.
17-
EXPECTED_INDEX_GROUP_COMMANDS = ["download", "index", "install", "list", "wheel"]
17+
EXPECTED_INDEX_GROUP_COMMANDS = [
18+
"download",
19+
"index",
20+
"install",
21+
"list",
22+
"lock",
23+
"wheel",
24+
]
1825

1926

2027
def check_commands(pred: Callable[[Command], bool], expected: List[str]) -> None:
@@ -53,7 +60,16 @@ def test_session_commands() -> None:
5360
def is_session_command(command: Command) -> bool:
5461
return isinstance(command, SessionCommandMixin)
5562

56-
expected = ["download", "index", "install", "list", "search", "uninstall", "wheel"]
63+
expected = [
64+
"download",
65+
"index",
66+
"install",
67+
"list",
68+
"lock",
69+
"search",
70+
"uninstall",
71+
"wheel",
72+
]
5773
check_commands(is_session_command, expected)
5874

5975

@@ -124,7 +140,7 @@ def test_requirement_commands() -> None:
124140
def is_requirement_command(command: Command) -> bool:
125141
return isinstance(command, RequirementCommand)
126142

127-
check_commands(is_requirement_command, ["download", "install", "wheel"])
143+
check_commands(is_requirement_command, ["download", "install", "lock", "wheel"])
128144

129145

130146
@pytest.mark.parametrize("flag", ["", "--outdated", "--uptodate"])

0 commit comments

Comments
 (0)