Skip to content

Commit 0b02620

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

File tree

4 files changed

+316
-0
lines changed

4 files changed

+316
-0
lines changed

Diff for: 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.

Diff for: 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",

Diff for: src/pip/_internal/commands/lock.py

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

Diff for: src/pip/_internal/models/pylock.py

+170
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, Literal, Self, Tuple
4+
5+
from pip._vendor.typing_extensions import Optional
6+
7+
from pip._internal.models.direct_url import ArchiveInfo, DirInfo, VcsInfo
8+
from pip._internal.models.link import Link
9+
from pip._internal.req.req_install import InstallRequirement
10+
from pip._internal.utils.urls import url_to_path
11+
12+
13+
def _toml_dict_factory(data: Iterable[Tuple[str, Any]]) -> Dict[str, Any]:
14+
return {key.replace("_", "-"): value for key, value in data if value is not None}
15+
16+
17+
@dataclass
18+
class PackageVcs:
19+
type: str
20+
url: Optional[str]
21+
# (not supported) path: Optional[str]
22+
requested_revision: Optional[str]
23+
commit_id: str
24+
subdirectory: Optional[str]
25+
26+
27+
@dataclass
28+
class PackageDirectory:
29+
path: str
30+
editable: Optional[bool]
31+
subdirectory: Optional[str]
32+
33+
34+
@dataclass
35+
class PackageArchive:
36+
url: Optional[str]
37+
# (not supported) path: Optional[str]
38+
# (not supported) size: Optional[int]
39+
hashes: Dict[str, str]
40+
subdirectory: Optional[str]
41+
42+
43+
@dataclass
44+
class PackageSdist:
45+
name: str
46+
# (not supported) upload_time
47+
url: Optional[str]
48+
# (not supported) path: Optional[str]
49+
# (not supported) size: Optional[int]
50+
hashes: Dict[str, str]
51+
52+
53+
@dataclass
54+
class PackageWheel:
55+
name: str
56+
# (not supported) upload_time
57+
url: Optional[str]
58+
# (not supported) path: Optional[str]
59+
# (not supported) size: Optional[int]
60+
hashes: Dict[str, str]
61+
62+
63+
@dataclass
64+
class Package:
65+
name: str
66+
version: Optional[str] = None
67+
# (not supported) marker: Optional[str]
68+
# (not supported) requires_python: Optional[str]
69+
# (not supported) dependencies
70+
direct: Optional[bool] = None
71+
vcs: Optional[PackageVcs] = None
72+
directory: Optional[PackageDirectory] = None
73+
archive: Optional[PackageArchive] = None
74+
# (not supported) index: Optional[str]
75+
sdist: Optional[PackageSdist] = None
76+
wheels: Optional[List[PackageWheel]] = None
77+
# (not supported) attestation_identities
78+
# (not supported) tool
79+
80+
@classmethod
81+
def from_install_requirement(cls, ireq: InstallRequirement) -> Self:
82+
assert ireq.name
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+
link = Link(download_info.url)
124+
if not download_info.info.hashes:
125+
raise NotImplementedError()
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: Literal["1.0"] = "1.0"
149+
# (not supported) environments
150+
# (not supported) requires_python
151+
created_by: str = "pip"
152+
packages: List[Package] = dataclasses.field(default_factory=list)
153+
# (not supported) tool
154+
155+
def to_dict(self) -> Dict[str, Any]:
156+
return 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+
)

0 commit comments

Comments
 (0)