Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PoC] PEP 751 pip lock command #13213

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/tomli-w.vendor.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Upgrade tomli-w to 1.2.0
5 changes: 5 additions & 0 deletions src/pip/_internal/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
"InstallCommand",
"Install packages.",
),
"lock": CommandInfo(
"pip._internal.commands.lock",
"LockCommand",
"Generate a lock file.",
),
"download": CommandInfo(
"pip._internal.commands.download",
"DownloadCommand",
Expand Down
164 changes: 164 additions & 0 deletions src/pip/_internal/commands/lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import sys
from optparse import Values
from pathlib import Path
from typing import List

from pip._internal.cache import WheelCache
from pip._internal.cli import cmdoptions
from pip._internal.cli.req_command import (
RequirementCommand,
with_cleanup,
)
from pip._internal.cli.status_codes import SUCCESS
from pip._internal.models.pylock import Pylock, is_valid_pylock_file_name
from pip._internal.operations.build.build_tracker import get_build_tracker
from pip._internal.req.req_install import (
check_legacy_setup_py_options,
)
from pip._internal.utils.logging import getLogger
from pip._internal.utils.misc import (
get_pip_version,
)
from pip._internal.utils.temp_dir import TempDirectory

logger = getLogger(__name__)


class LockCommand(RequirementCommand):
"""
Lock packages from:

- PyPI (and other indexes) using requirement specifiers.
- VCS project urls.
- Local project directories.
- Local or remote source archives.

pip also supports locking from "requirements files", which provide
an easy way to specify a whole environment to be installed.
"""

usage = """
%prog [options] <requirement specifier> [package-index-options] ...
%prog [options] -r <requirements file> [package-index-options] ...
%prog [options] [-e] <vcs project url> ...
%prog [options] [-e] <local project path> ...
%prog [options] <archive url/path> ..."""

def add_options(self) -> None:
self.cmd_opts.add_option(
cmdoptions.PipOption(
"--output-file",
"--output",
"-o",
dest="output_file",
metavar="path",
type="path",
default="pylock.toml",
help="Lock file name (default=pylock.toml). Use - for stdout.",
)
)
self.cmd_opts.add_option(cmdoptions.requirements())
self.cmd_opts.add_option(cmdoptions.constraints())
self.cmd_opts.add_option(cmdoptions.no_deps())
self.cmd_opts.add_option(cmdoptions.pre())

self.cmd_opts.add_option(cmdoptions.editable())

self.cmd_opts.add_option(cmdoptions.src())

self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
self.cmd_opts.add_option(cmdoptions.no_build_isolation())
self.cmd_opts.add_option(cmdoptions.use_pep517())
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
self.cmd_opts.add_option(cmdoptions.check_build_deps())

self.cmd_opts.add_option(cmdoptions.config_settings())

self.cmd_opts.add_option(cmdoptions.no_binary())
self.cmd_opts.add_option(cmdoptions.only_binary())
self.cmd_opts.add_option(cmdoptions.prefer_binary())
self.cmd_opts.add_option(cmdoptions.require_hashes())
self.cmd_opts.add_option(cmdoptions.progress_bar())

index_opts = cmdoptions.make_option_group(
cmdoptions.index_group,
self.parser,
)

self.parser.insert_option_group(0, index_opts)
self.parser.insert_option_group(0, self.cmd_opts)

@with_cleanup
def run(self, options: Values, args: List[str]) -> int:
logger.verbose("Using %s", get_pip_version())

session = self.get_default_session(options)

finder = self._build_package_finder(
options=options,
session=session,
ignore_requires_python=options.ignore_requires_python,
)
build_tracker = self.enter_context(get_build_tracker())

directory = TempDirectory(
delete=not options.no_clean,
kind="install",
globally_managed=True,
)

reqs = self.get_requirements(args, options, finder, session)
check_legacy_setup_py_options(options, reqs)

wheel_cache = WheelCache(options.cache_dir)

# Only when installing is it permitted to use PEP 660.
# In other circumstances (pip wheel, pip download) we generate
# regular (i.e. non editable) metadata and wheels.
for req in reqs:
req.permit_editable_wheels = True

preparer = self.make_requirement_preparer(
temp_build_dir=directory,
options=options,
build_tracker=build_tracker,
session=session,
finder=finder,
use_user_site=False,
verbosity=self.verbosity,
)
resolver = self.make_resolver(
preparer=preparer,
finder=finder,
options=options,
wheel_cache=wheel_cache,
use_user_site=False,
ignore_installed=True,
ignore_requires_python=options.ignore_requires_python,
upgrade_strategy="to-satisfy-only",
use_pep517=options.use_pep517,
)

self.trace_basic_info(finder)

requirement_set = resolver.resolve(reqs, check_supported_wheels=True)

if options.output_file == "-":
base_dir = Path.cwd()
else:
output_file_path = Path(options.output_file)
if not is_valid_pylock_file_name(output_file_path):
logger.warning(
"%s is not a valid lock file name.",
output_file_path,
)
base_dir = output_file_path.parent.absolute()
pylock_toml = Pylock.from_install_requirements(
requirement_set.requirements.values(), base_dir=base_dir
).as_toml()
if options.output_file == "-":
sys.stdout.write(pylock_toml)
else:
output_file_path.write_text(pylock_toml, encoding="utf-8")

return SUCCESS
180 changes: 180 additions & 0 deletions src/pip/_internal/models/pylock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import dataclasses
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

from pip._vendor import tomli_w
from pip._vendor.typing_extensions import Self

from pip._internal.models.direct_url import ArchiveInfo, DirInfo, VcsInfo
from pip._internal.models.link import Link
from pip._internal.req.req_install import InstallRequirement
from pip._internal.utils.urls import url_to_path

PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$")


def is_valid_pylock_file_name(path: Path) -> bool:
return path.name == "pylock.toml" or bool(re.match(PYLOCK_FILE_NAME_RE, path.name))


def _toml_dict_factory(data: Iterable[Tuple[str, Any]]) -> Dict[str, Any]:
return {key.replace("_", "-"): value for key, value in data if value is not None}


@dataclass
class PackageVcs:
type: str
url: Optional[str]
# (not supported) path: Optional[str]
requested_revision: Optional[str]
commit_id: str
subdirectory: Optional[str]


@dataclass
class PackageDirectory:
path: str
editable: Optional[bool]
subdirectory: Optional[str]


@dataclass
class PackageArchive:
url: Optional[str]
# (not supported) path: Optional[str]
# (not supported) size: Optional[int]
hashes: Dict[str, str]
subdirectory: Optional[str]


@dataclass
class PackageSdist:
name: str
# (not supported) upload_time: Optional[datetime]
url: Optional[str]
# (not supported) path: Optional[str]
# (not supported) size: Optional[int]
hashes: Dict[str, str]


@dataclass
class PackageWheel:
name: str
# (not supported) upload_time: Optional[datetime]
url: Optional[str]
# (not supported) path: Optional[str]
# (not supported) size: Optional[int]
hashes: Dict[str, str]


@dataclass
class Package:
name: str
version: Optional[str] = None
# (not supported) marker: Optional[str]
# (not supported) requires_python: Optional[str]
# (not supported) dependencies
direct: Optional[bool] = None
vcs: Optional[PackageVcs] = None
directory: Optional[PackageDirectory] = None
archive: Optional[PackageArchive] = None
# (not supported) index: Optional[str]
sdist: Optional[PackageSdist] = None
wheels: Optional[List[PackageWheel]] = None
# (not supported) attestation_identities: Optional[List[Dict[str, Any]]]
# (not supported) tool: Optional[Dict[str, Any]]

@classmethod
def from_install_requirement(cls, ireq: InstallRequirement, base_dir: Path) -> Self:
dist = ireq.get_dist()
download_info = ireq.download_info
assert download_info
package = cls(name=dist.canonical_name)
package.direct = ireq.is_direct if ireq.is_direct else None
if package.direct:
if isinstance(download_info.info, VcsInfo):
package.vcs = PackageVcs(
type=download_info.info.vcs,
url=download_info.url,
requested_revision=download_info.info.requested_revision,
commit_id=download_info.info.commit_id,
subdirectory=download_info.subdirectory,
)
elif isinstance(download_info.info, DirInfo):
package.directory = PackageDirectory(
path=(
Path(url_to_path(download_info.url))
.relative_to(base_dir, walk_up=True)
.as_posix()
),
editable=(
download_info.info.editable
if download_info.info.editable
else None
),
subdirectory=download_info.subdirectory,
)
elif isinstance(download_info.info, ArchiveInfo):
if not download_info.info.hashes:
raise NotImplementedError()
package.archive = PackageArchive(
url=download_info.url,
hashes=download_info.info.hashes,
subdirectory=download_info.subdirectory,
)
else:
# should never happen
raise NotImplementedError()
else:
package.version = str(dist.version)
if isinstance(download_info.info, ArchiveInfo):
if not download_info.info.hashes:
raise NotImplementedError()
link = Link(download_info.url)
if link.is_wheel:
package.wheels = [
PackageWheel(
name=link.filename,
url=download_info.url,
hashes=download_info.info.hashes,
)
]
else:
package.sdist = PackageSdist(
name=link.filename,
url=download_info.url,
hashes=download_info.info.hashes,
)
else:
# should never happen
raise NotImplementedError()
return package


@dataclass
class Pylock:
lock_version: str = "1.0"
# (not supported) environments: Optional[List[str]]
# (not supported) requires_python: Optional[str]
created_by: str = "pip"
packages: List[Package] = dataclasses.field(default_factory=list)
# (not supported) tool: Optional[Dict[str, Any]]

def as_toml(self) -> str:
return tomli_w.dumps(dataclasses.asdict(self, dict_factory=_toml_dict_factory))

@classmethod
def from_install_requirements(
cls, install_requirements: Iterable[InstallRequirement], base_dir: Path
) -> Self:
return cls(
packages=sorted(
(
Package.from_install_requirement(ireq, base_dir)
for ireq in install_requirements
),
key=lambda p: p.name,
)
)
21 changes: 21 additions & 0 deletions src/pip/_vendor/tomli_w/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Taneli Hukkinen

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
4 changes: 4 additions & 0 deletions src/pip/_vendor/tomli_w/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__all__ = ("dumps", "dump")
__version__ = "1.2.0" # DO NOT EDIT THIS LINE MANUALLY. LET bump2version UTILITY DO IT

from pip._vendor.tomli_w._writer import dump, dumps
Loading
Loading