diff --git a/docs/html/cli/index.md b/docs/html/cli/index.md index 902fe406e6e..a3aef3b10ba 100644 --- a/docs/html/cli/index.md +++ b/docs/html/cli/index.md @@ -23,6 +23,13 @@ pip_freeze pip_check ``` +```{toctree} +:maxdepth: 1 +:caption: Resolving dependencies + +pip_lock +``` + ```{toctree} :maxdepth: 1 :caption: Handling Distribution Files diff --git a/docs/html/cli/pip_lock.rst b/docs/html/cli/pip_lock.rst new file mode 100644 index 00000000000..8465df38518 --- /dev/null +++ b/docs/html/cli/pip_lock.rst @@ -0,0 +1,50 @@ + +.. _`pip lock`: + +======== +pip lock +======== + + + +Usage +===== + +.. tab:: Unix/macOS + + .. pip-command-usage:: lock "python -m pip" + +.. tab:: Windows + + .. pip-command-usage:: lock "py -m pip" + + +Description +=========== + +.. pip-command-description:: lock + +Options +======= + +.. pip-command-options:: lock + +.. pip-index-options:: lock + + +Examples +======== + +#. Emit a ``pylock.toml`` for the the project in the current directory + + .. tab:: Unix/macOS + + .. code-block:: shell + + python -m pip lock -e . + + .. tab:: Windows + + .. code-block:: shell + + py -m pip lock -e . diff --git a/docs/man/commands/lock.rst b/docs/man/commands/lock.rst new file mode 100644 index 00000000000..ca1de2ce28c --- /dev/null +++ b/docs/man/commands/lock.rst @@ -0,0 +1,20 @@ +:orphan: + +======== +pip-lock +======== + +Description +*********** + +.. pip-command-description:: lock + +Usage +***** + +.. pip-command-usage:: lock + +Options +******* + +.. pip-command-options:: lock diff --git a/docs/man/index.rst b/docs/man/index.rst index 526e83e8883..f45f0d4fed2 100644 --- a/docs/man/index.rst +++ b/docs/man/index.rst @@ -34,6 +34,9 @@ pip-uninstall(1) pip-freeze(1) Output installed packages in requirements format. +pip-lock(1) + Generate a lock file for requirements and their dependencies. + pip-list(1) List installed packages. diff --git a/news/tomli-w.vendor.rst b/news/tomli-w.vendor.rst new file mode 100644 index 00000000000..92e0d41fc70 --- /dev/null +++ b/news/tomli-w.vendor.rst @@ -0,0 +1 @@ +Vendor tomli-w 1.2.0 diff --git a/src/pip/_internal/commands/__init__.py b/src/pip/_internal/commands/__init__.py index 858a4101416..bc4f216a826 100644 --- a/src/pip/_internal/commands/__init__.py +++ b/src/pip/_internal/commands/__init__.py @@ -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", diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py new file mode 100644 index 00000000000..39f27e8677b --- /dev/null +++ b/src/pip/_internal/commands/lock.py @@ -0,0 +1,171 @@ +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): + """ + EXPERIMENTAL - Lock packages and their dependencies 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. + + The generated lock file is only guaranteed to be valid for the current + python version and platform. + """ + + usage = """ + %prog [options] [-e] ... + %prog [options] [package-index-options] ... + %prog [options] -r [package-index-options] ... + %prog [options] ...""" + + def add_options(self) -> None: + self.cmd_opts.add_option( + cmdoptions.PipOption( + "--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()) + + logger.warning( + "pip lock is currently an experimental command. " + "It may be removed/changed in a future release " + "without prior warning." + ) + + 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 + 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 diff --git a/src/pip/_internal/models/pylock.py b/src/pip/_internal/models/pylock.py new file mode 100644 index 00000000000..d9decb2964f --- /dev/null +++ b/src/pip/_internal/models/pylock.py @@ -0,0 +1,183 @@ +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: List[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] + # (not supported) upload_time: Optional[datetime] + 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 + 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: + base_dir = base_dir.resolve() + dist = ireq.get_dist() + download_info = ireq.download_info + assert download_info + package = cls(name=dist.canonical_name) + if ireq.is_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)) + .resolve() + .relative_to(base_dir) + .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] + # (not supported) extras: List[str] = [] + # (not supported) dependency_groups: List[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, + ) + ) diff --git a/src/pip/_vendor/tomli_w/LICENSE b/src/pip/_vendor/tomli_w/LICENSE new file mode 100644 index 00000000000..e859590f886 --- /dev/null +++ b/src/pip/_vendor/tomli_w/LICENSE @@ -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. diff --git a/src/pip/_vendor/tomli_w/__init__.py b/src/pip/_vendor/tomli_w/__init__.py new file mode 100644 index 00000000000..6349c1f05bc --- /dev/null +++ b/src/pip/_vendor/tomli_w/__init__.py @@ -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 diff --git a/src/pip/_vendor/tomli_w/_writer.py b/src/pip/_vendor/tomli_w/_writer.py new file mode 100644 index 00000000000..b1acd3f26ba --- /dev/null +++ b/src/pip/_vendor/tomli_w/_writer.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import date, datetime, time +from types import MappingProxyType + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Generator + from decimal import Decimal + from typing import IO, Any, Final + +ASCII_CTRL = frozenset(chr(i) for i in range(32)) | frozenset(chr(127)) +ILLEGAL_BASIC_STR_CHARS = frozenset('"\\') | ASCII_CTRL - frozenset("\t") +BARE_KEY_CHARS = frozenset( + "abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789" "-_" +) +ARRAY_TYPES = (list, tuple) +MAX_LINE_LENGTH = 100 + +COMPACT_ESCAPES = MappingProxyType( + { + "\u0008": "\\b", # backspace + "\u000A": "\\n", # linefeed + "\u000C": "\\f", # form feed + "\u000D": "\\r", # carriage return + "\u0022": '\\"', # quote + "\u005C": "\\\\", # backslash + } +) + + +class Context: + def __init__(self, allow_multiline: bool, indent: int): + if indent < 0: + raise ValueError("Indent width must be non-negative") + self.allow_multiline: Final = allow_multiline + # cache rendered inline tables (mapping from object id to rendered inline table) + self.inline_table_cache: Final[dict[int, str]] = {} + self.indent_str: Final = " " * indent + + +def dump( + obj: Mapping[str, Any], + fp: IO[bytes], + /, + *, + multiline_strings: bool = False, + indent: int = 4, +) -> None: + ctx = Context(multiline_strings, indent) + for chunk in gen_table_chunks(obj, ctx, name=""): + fp.write(chunk.encode()) + + +def dumps( + obj: Mapping[str, Any], /, *, multiline_strings: bool = False, indent: int = 4 +) -> str: + ctx = Context(multiline_strings, indent) + return "".join(gen_table_chunks(obj, ctx, name="")) + + +def gen_table_chunks( + table: Mapping[str, Any], + ctx: Context, + *, + name: str, + inside_aot: bool = False, +) -> Generator[str, None, None]: + yielded = False + literals = [] + tables: list[tuple[str, Any, bool]] = [] # => [(key, value, inside_aot)] + for k, v in table.items(): + if isinstance(v, Mapping): + tables.append((k, v, False)) + elif is_aot(v) and not all(is_suitable_inline_table(t, ctx) for t in v): + tables.extend((k, t, True) for t in v) + else: + literals.append((k, v)) + + if inside_aot or name and (literals or not tables): + yielded = True + yield f"[[{name}]]\n" if inside_aot else f"[{name}]\n" + + if literals: + yielded = True + for k, v in literals: + yield f"{format_key_part(k)} = {format_literal(v, ctx)}\n" + + for k, v, in_aot in tables: + if yielded: + yield "\n" + else: + yielded = True + key_part = format_key_part(k) + display_name = f"{name}.{key_part}" if name else key_part + yield from gen_table_chunks(v, ctx, name=display_name, inside_aot=in_aot) + + +def format_literal(obj: object, ctx: Context, *, nest_level: int = 0) -> str: + if isinstance(obj, bool): + return "true" if obj else "false" + if isinstance(obj, (int, float, date, datetime)): + return str(obj) + if isinstance(obj, time): + if obj.tzinfo: + raise ValueError("TOML does not support offset times") + return str(obj) + if isinstance(obj, str): + return format_string(obj, allow_multiline=ctx.allow_multiline) + if isinstance(obj, ARRAY_TYPES): + return format_inline_array(obj, ctx, nest_level) + if isinstance(obj, Mapping): + return format_inline_table(obj, ctx) + + # Lazy import to improve module import time + from decimal import Decimal + + if isinstance(obj, Decimal): + return format_decimal(obj) + raise TypeError( + f"Object of type '{type(obj).__qualname__}' is not TOML serializable" + ) + + +def format_decimal(obj: Decimal) -> str: + if obj.is_nan(): + return "nan" + if obj.is_infinite(): + return "-inf" if obj.is_signed() else "inf" + dec_str = str(obj).lower() + return dec_str if "." in dec_str or "e" in dec_str else dec_str + ".0" + + +def format_inline_table(obj: Mapping, ctx: Context) -> str: + # check cache first + obj_id = id(obj) + if obj_id in ctx.inline_table_cache: + return ctx.inline_table_cache[obj_id] + + if not obj: + rendered = "{}" + else: + rendered = ( + "{ " + + ", ".join( + f"{format_key_part(k)} = {format_literal(v, ctx)}" + for k, v in obj.items() + ) + + " }" + ) + ctx.inline_table_cache[obj_id] = rendered + return rendered + + +def format_inline_array(obj: tuple | list, ctx: Context, nest_level: int) -> str: + if not obj: + return "[]" + item_indent = ctx.indent_str * (1 + nest_level) + closing_bracket_indent = ctx.indent_str * nest_level + return ( + "[\n" + + ",\n".join( + item_indent + format_literal(item, ctx, nest_level=nest_level + 1) + for item in obj + ) + + f",\n{closing_bracket_indent}]" + ) + + +def format_key_part(part: str) -> str: + try: + only_bare_key_chars = BARE_KEY_CHARS.issuperset(part) + except TypeError: + raise TypeError( + f"Invalid mapping key '{part}' of type '{type(part).__qualname__}'." + " A string is required." + ) from None + + if part and only_bare_key_chars: + return part + return format_string(part, allow_multiline=False) + + +def format_string(s: str, *, allow_multiline: bool) -> str: + do_multiline = allow_multiline and "\n" in s + if do_multiline: + result = '"""\n' + s = s.replace("\r\n", "\n") + else: + result = '"' + + pos = seq_start = 0 + while True: + try: + char = s[pos] + except IndexError: + result += s[seq_start:pos] + if do_multiline: + return result + '"""' + return result + '"' + if char in ILLEGAL_BASIC_STR_CHARS: + result += s[seq_start:pos] + if char in COMPACT_ESCAPES: + if do_multiline and char == "\n": + result += "\n" + else: + result += COMPACT_ESCAPES[char] + else: + result += "\\u" + hex(ord(char))[2:].rjust(4, "0") + seq_start = pos + 1 + pos += 1 + + +def is_aot(obj: Any) -> bool: + """Decides if an object behaves as an array of tables (i.e. a nonempty list + of dicts).""" + return bool( + isinstance(obj, ARRAY_TYPES) + and obj + and all(isinstance(v, Mapping) for v in obj) + ) + + +def is_suitable_inline_table(obj: Mapping, ctx: Context) -> bool: + """Use heuristics to decide if the inline-style representation is a good + choice for a given table.""" + rendered_inline = f"{ctx.indent_str}{format_inline_table(obj, ctx)}," + return len(rendered_inline) <= MAX_LINE_LENGTH and "\n" not in rendered_inline diff --git a/src/pip/_vendor/tomli_w/py.typed b/src/pip/_vendor/tomli_w/py.typed new file mode 100644 index 00000000000..7632ecf7754 --- /dev/null +++ b/src/pip/_vendor/tomli_w/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/src/pip/_vendor/vendor.txt b/src/pip/_vendor/vendor.txt index 0c7733ff8a5..447d3a22487 100644 --- a/src/pip/_vendor/vendor.txt +++ b/src/pip/_vendor/vendor.txt @@ -15,5 +15,6 @@ rich==13.9.4 resolvelib==1.1.0 setuptools==70.3.0 tomli==2.2.1 +tomli-w==1.2.0 truststore==0.10.1 dependency-groups==1.3.0 diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py index 366d0129b2d..6d2e2a12935 100644 --- a/tests/functional/test_cli.py +++ b/tests/functional/test_cli.py @@ -57,7 +57,7 @@ def test_entrypoints_work(entrypoint: str, script: PipTestEnvironment) -> None: sorted( set(commands_dict).symmetric_difference( # Exclude commands that are expected to use the network. - {"install", "download", "search", "index", "wheel"} + {"install", "download", "search", "index", "lock", "wheel"} ) ), ) diff --git a/tests/functional/test_lock.py b/tests/functional/test_lock.py new file mode 100644 index 00000000000..861fc87b6d4 --- /dev/null +++ b/tests/functional/test_lock.py @@ -0,0 +1,237 @@ +import sys +import textwrap +from pathlib import Path + +from pip._internal.utils.urls import path_to_url + +from ..lib import PipTestEnvironment, TestData + +if sys.version_info >= (3, 11): + import tomllib +else: + from pip._vendor import tomli as tomllib + + +def test_lock_wheel_from_findlinks( + script: PipTestEnvironment, shared_data: TestData, tmp_path: Path +) -> None: + """Test locking a simple wheel package, to the default pylock.toml.""" + result = script.pip( + "lock", + "simplewheel==2.0", + "--no-index", + "--find-links", + str(shared_data.root / "packages/"), + expect_stderr=True, # for the experimental warning + ) + result.did_create(Path("scratch") / "pylock.toml") + pylock = tomllib.loads(script.scratch_path.joinpath("pylock.toml").read_text()) + assert pylock == { + "created-by": "pip", + "lock-version": "1.0", + "packages": [ + { + "name": "simplewheel", + "version": "2.0", + "wheels": [ + { + "name": "simplewheel-2.0-1-py2.py3-none-any.whl", + "url": path_to_url( + str( + shared_data.root + / "packages" + / "simplewheel-2.0-1-py2.py3-none-any.whl" + ) + ), + "hashes": { + "sha256": ( + "71e1ca6b16ae3382a698c284013f6650" + "4f2581099b2ce4801f60e9536236ceee" + ) + }, + } + ], + }, + ], + } + + +def test_lock_sdist_from_findlinks( + script: PipTestEnvironment, shared_data: TestData +) -> None: + """Test locking a simple wheel package, to the default pylock.toml.""" + result = script.pip( + "lock", + "simple==2.0", + "--no-binary=simple", + "--quiet", + "--output=-", + "--no-index", + "--find-links", + str(shared_data.root / "packages/"), + expect_stderr=True, # for the experimental warning + ) + pylock = tomllib.loads(result.stdout) + assert pylock["packages"] == [ + { + "name": "simple", + "sdist": { + "hashes": { + "sha256": ( + "3a084929238d13bcd3bb928af04f3bac" + "7ca2357d419e29f01459dc848e2d69a4" + ), + }, + "name": "simple-2.0.tar.gz", + "url": path_to_url( + str(shared_data.root / "packages" / "simple-2.0.tar.gz") + ), + }, + "version": "2.0", + }, + ] + + +def test_lock_local_directory( + script: PipTestEnvironment, shared_data: TestData, tmp_path: Path +) -> None: + project_path = tmp_path / "pkga" + project_path.mkdir() + project_path.joinpath("pyproject.toml").write_text( + textwrap.dedent( + """\ + [project] + name = "pkga" + version = "1.0" + """ + ) + ) + result = script.pip( + "lock", + ".", + "--quiet", + "--output=-", + "--no-build-isolation", # to use the pre-installed setuptools + "--no-index", + "--find-links", + str(shared_data.root / "packages/"), + cwd=project_path, + expect_stderr=True, # for the experimental warning + ) + pylock = tomllib.loads(result.stdout) + assert pylock["packages"] == [ + { + "name": "pkga", + "directory": {"path": "."}, + }, + ] + + +def test_lock_local_editable_with_dep( + script: PipTestEnvironment, shared_data: TestData, tmp_path: Path +) -> None: + project_path = tmp_path / "pkga" + project_path.mkdir() + project_path.joinpath("pyproject.toml").write_text( + textwrap.dedent( + """\ + [project] + name = "pkga" + version = "1.0" + dependencies = ["simplewheel==2.0"] + """ + ) + ) + result = script.pip( + "lock", + "-e", + ".", + "--quiet", + "--output=-", + "--no-build-isolation", # to use the pre-installed setuptools + "--no-index", + "--find-links", + str(shared_data.root / "packages/"), + cwd=project_path, + expect_stderr=True, # for the experimental warning + ) + pylock = tomllib.loads(result.stdout) + assert pylock["packages"] == [ + { + "name": "pkga", + "directory": {"editable": True, "path": "."}, + }, + { + "name": "simplewheel", + "version": "2.0", + "wheels": [ + { + "name": "simplewheel-2.0-1-py2.py3-none-any.whl", + "url": path_to_url( + str( + shared_data.root + / "packages" + / "simplewheel-2.0-1-py2.py3-none-any.whl" + ) + ), + "hashes": { + "sha256": ( + "71e1ca6b16ae3382a698c284013f6650" + "4f2581099b2ce4801f60e9536236ceee" + ) + }, + } + ], + }, + ] + + +def test_lock_vcs(script: PipTestEnvironment, shared_data: TestData) -> None: + result = script.pip( + "lock", + "git+https://github.com/pypa/pip-test-package@0.1.2", + "--quiet", + "--output=-", + "--no-build-isolation", # to use the pre-installed setuptools + "--no-index", + expect_stderr=True, # for the experimental warning + ) + pylock = tomllib.loads(result.stdout) + assert pylock["packages"] == [ + { + "name": "pip-test-package", + "vcs": { + "type": "git", + "url": "https://github.com/pypa/pip-test-package", + "requested-revision": "0.1.2", + "commit-id": "f1c1020ebac81f9aeb5c766ff7a772f709e696ee", + }, + }, + ] + + +def test_lock_archive(script: PipTestEnvironment, shared_data: TestData) -> None: + result = script.pip( + "lock", + "https://github.com/pypa/pip-test-package/tarball/0.1.2", + "--quiet", + "--output=-", + "--no-build-isolation", # to use the pre-installed setuptools + "--no-index", + expect_stderr=True, # for the experimental warning + ) + pylock = tomllib.loads(result.stdout) + assert pylock["packages"] == [ + { + "name": "pip-test-package", + "archive": { + "url": "https://github.com/pypa/pip-test-package/tarball/0.1.2", + "hashes": { + "sha256": ( + "1b176298e5ecd007da367bfda91aad3c" + "4a6534227faceda087b00e5b14d596bf" + ), + }, + }, + }, + ] diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 9d5aefec298..59c0b8821da 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -14,7 +14,14 @@ # These are the expected names of the commands whose classes inherit from # IndexGroupCommand. -EXPECTED_INDEX_GROUP_COMMANDS = ["download", "index", "install", "list", "wheel"] +EXPECTED_INDEX_GROUP_COMMANDS = [ + "download", + "index", + "install", + "list", + "lock", + "wheel", +] def check_commands(pred: Callable[[Command], bool], expected: List[str]) -> None: @@ -53,7 +60,16 @@ def test_session_commands() -> None: def is_session_command(command: Command) -> bool: return isinstance(command, SessionCommandMixin) - expected = ["download", "index", "install", "list", "search", "uninstall", "wheel"] + expected = [ + "download", + "index", + "install", + "list", + "lock", + "search", + "uninstall", + "wheel", + ] check_commands(is_session_command, expected) @@ -124,7 +140,7 @@ def test_requirement_commands() -> None: def is_requirement_command(command: Command) -> bool: return isinstance(command, RequirementCommand) - check_commands(is_requirement_command, ["download", "install", "wheel"]) + check_commands(is_requirement_command, ["download", "install", "lock", "wheel"]) @pytest.mark.parametrize("flag", ["", "--outdated", "--uptodate"])