Skip to content

Commit 914bd0b

Browse files
committed
[WIP] PEP 751: pip lock command
1 parent 028c087 commit 914bd0b

File tree

4 files changed

+316
-0
lines changed

4 files changed

+316
-0
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
authors = [
2626
{name = "The pip developers", email = "[email protected]"},
2727
]
28+
dependencies = ["tomli-w"] # TODO: vendor this
2829

2930
# NOTE: requires-python is duplicated in __pip-runner__.py.
3031
# When changing this value, please change the other copy as well.

src/pip/_internal/commands/__init__.py

+5
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

+138
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

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

0 commit comments

Comments
 (0)