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"])