Repository: pypa/pip
Pull request #13213 (2025-02-08 19:09:29)

PEP 751 experimental `pip lock` command
This is a ~rough~ ~PoC for~ a `pip lock` command that writes PEP 751 compliant toml ~to stdout~.

Use with:
-  `pip --quiet lock -o - .` to lock the local project to stdout.
- `pip lock -e .` to lock the local project in editable mode to `pylock.toml`

I wrote this mainly as a way to better understand the PEP.

A few comments:
- This command works with the same logic as `pip install --dry-run --ignore-installed`, with mostly the same options.
- I removed the platform/abi/implementation/python-version options due to their limitations (#11664). We could keep them with the same caveat as for the other commands...
- ...but it is unclear to me if pip in its current state could output something useful for `environments`, `requires-python`, `packages.marker`.
- This command begs for a `--only-deps` option (#11440)
- The tables are currently not inlined which makes the output somewhat hard to grok.


potiuk: Nice. I would love to help with making it happen (of course it depends on PEP 751 to be approved).  We are currently doing our own custom and pretty "poor" implementation of locks with cosntraints in Airlfow and having a "standard" way of reproducible installs would be a great thing.

Not sure what is the status now of the PEP and whether it has a chance to be approved soon, but that looks really great that we are trying to standardise it now.
notatallshaw: I appreciate this is draft, but some small early feedback.

Firstly, I am advocating for a an "output file" option. Writing pip's output to a file in other commands has several footguns, e.g knowing the correct flags to turn off non-relevant output, determining if pip has errored or not when you do, and  writing non-UTF-8 files when you direct standard out from powershell on Windows.

pip-compile (and uv pip compile) solve this by having an output file option which is considered best practice to use. That said, I don't know the history of why other commands don't offer this, perhaps it's non-trivial.

> I removed the platform/abi/implementation/python-version options due to their limitations

I agree, I think pip should start as limited as possible around known difficult issues, and expand from there. This will give the chance for the lock API to evolve, if able.



pfmoore: Cool. I did some early PoC work on the PEP, but things have evolved drastically since then, and I never got back to it. But one of my goals was always for the pip installation report format to be easily convertible into a lockfile. Another thing I'd like to see pip implement (I may well get round to this myself, in due course, it's not a request for someone else to pick it up!) is `pip freeze --format=lockfile`. I'd hope that would be relatively easy to implement.

I'd very definitely aim pip at *solely* producing "environment reproduction" lockfiles, and not cross-platform ones. To that end, I think that disallowing the `--platform` etc., arguments[^1] is the right call.

I don't have a strong opinion on the question of an `--output-file` option, but I think that defaulting to writing to stdout is a reasonable place to start. With an output file option, we'd have to consider validating the provided filename to match the standard-required form `pylock[.xxx].toml`, which IMO is a distraction from the main exercise of ensuring that we produce a correct lockfile.

> Not sure what is the status now of the PEP and whether it has a chance to be approved soon

The PEP is in its final stages before being submitted for pronouncement. At this point, it's extremely unlikely that any major changes will occur. Speaking as the PEP-delegate, I can say that I've been heavily involved in the discussions on the PEP, and I'm confident that it has a good chance of being accepted[^2].

[^1]: Or, as you say, putting them under a "use at your own risk" warning.
[^2]: I'm not giving anything away here - I've always been supportive of the idea of a lockfile standard, but the problem has been getting community consensus on what it should look like. At this point, we're the closest we've ever been to consensus.
sbidoul: > pip freeze --format=lockfile

I think a pre-requisite for that is [PEP 710](https://peps.python.org/pep-0710/)
notatallshaw: > I don't have a strong opinion on the question of an `--output-file` option, but I think that defaulting to writing to stdout is a reasonable place to start. With an output file option, we'd have to consider validating the provided filename to match the standard-required form `pylock[.xxx].toml`, which IMO is a distraction from the main exercise of ensuring that we produce a correct lockfile.

I'm fine with this being in a follow up PR if there are non-trivial questions to answer.

Though looking now at the PEP I do think a pedantic reading of it would be that the data should only ever be written as a file and not outputted as stdout as that would _technically_ violate those same file name specifications 😉 . 


pfmoore: 🙂 I wasn't trying to be pedantic, just noting the likelihood that *someone* would ask the question about validating the filename. Getting the encoding correct by using the `--output-file` option is a much more important point, though.
sbidoul: I choose not to emit `version` for direct URL packages, as the version could by dynamic. This is to avoid problems with projects using `setuptools-scm`, where the version possibly changes with each commit and committing the lock file would change the version.

Also, I choose to emit a relative path for `directory` packages, so the common use case (namely `pip lock .`) produces something that is portable. 

sbidoul: Now that PEP 751 is accepted, let's see if we want to move forward with this `pip lock` command.

A few questions I have:

- Is this CLI good as it is now ?
- Do we want to emit `environments` ? The only thing that can be done easily is to emit the full current environment, but that is of course way to restrictive in most cases if installers would refuse to install if the installation environment does not match the lock environment. I'm enclined to leave it out in a first iteration.
- The output format could be optimized by inlining some tables. Do we want to improve that ? `tomli-w` does not support that. `tomlkit` could be an option but it is heavier and harder to use leading to less readable code. Is there another option? We could also postpone inlining and see if this is actually a problem in practice.

pfmoore: +1 from me. I think the CLI is good (we can make changes later if needed). I agree, let's not emit `environments` for now at least.

Let's not worry about inlining for now. Layout is an area where there are a number of possible options, and I imagine tools will ultimately converge on an approach that provides good readability/auditability and easy generation. It's possible that someone could even produce a dedicated lockfile writer module. I'd rather we stuck with something simple, and with a smaller new library to vendor, until we have a better feel for where the community is going here.
sbidoul: Good, I'll see if I can add some tests in time for 25.1.

Except for the absence tests, this is ready to review. 
pfmoore: I've added the 25.1 milestone, but if you don't have the time (we're about 2 weeks away from the release) feel free to reassign to 25.2 or just remove the milestone.
ichard26: I haven't reviewed the PR at all, but if we're going to land this feature for pip 25.1, would it be beneficial to mark this new command as experimental like `pip index version`? As I said, I haven't reviewed the PR so maybe this command is already well-designed, but I'd rather be honest and give ourselves some breathing room as the ecosystem plays with the new standard. Despite the multi-year discussion, I don't think we'll be able to nail the UI side of lockfiles on our first try. 
uranusjr: +1 to marking this as experiemental.
paveldikov: Question: this PR implements a command that _generates_ a lockfile.

How would one install from the lockfile, though? Would this be by way of `pip install` (e.g. `pip install -l pylock.toml`) or a dedicated command such as `pip sync`? Or something completely different?
pfmoore: No-one has yet implemented a `pip install --lockfile pylock.toml` command, but it's certainly something we expect to add. We won't (at least, I don't expect we will) be implementing a `pip sync` style command, as that's a higher level interface, more likely to be appropriate in tools like hatch and PDM.
sbidoul: I added the documentation pages, and an experimental warning.
sbidoul: We should probably document that the generated lock file is not portable across platforms or Python versions, except in simple cases.
sbidoul: I documented that the lock file is single platform, and added a first basic test. 
sbidoul: There is now decent test coverage. This should be good to go.

Patch:
diff --git a/docs/html/cli/index.md b/docs/html/cli/index.md
index 902fe406e..a3aef3b10 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 000000000..8465df385
--- /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 000000000..ca1de2ce2
--- /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 526e83e88..f45f0d4fe 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 000000000..92e0d41fc
--- /dev/null
+++ b/news/tomli-w.vendor.rst
@@ -0,0 +1,1 @@
+Vendor tomli-w 1.2.0
diff --git a/src/pip/_internal/commands/__init__.py b/src/pip/_internal/commands/__init__.py
index 858a41014..bc4f216a8 100644
--- a/src/pip/_internal/commands/__init__.py
+++ b/src/pip/_internal/commands/__init__.py
@@ -23,6 +23,11 @@ commands_dict: Dict[str, CommandInfo] = {
         "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/freeze.py b/src/pip/_internal/commands/freeze.py
index f8de335b2..885fdfeb8 100644
--- a/src/pip/_internal/commands/freeze.py
+++ b/src/pip/_internal/commands/freeze.py
@@ -32,6 +32,7 @@ class FreezeCommand(Command):
     ignore_require_venv = True
     usage = """
       %prog [options]"""
+    log_streams = ("ext://sys.stderr", "ext://sys.stderr")
 
     def add_options(self) -> None:
         self.cmd_opts.add_option(
diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py
new file mode 100644
index 000000000..39f27e867
--- /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] <local project path> ...
+      %prog [options] <requirement specifier> [package-index-options] ...
+      %prog [options] -r <requirements file> [package-index-options] ...
+      %prog [options] <archive url/path> ..."""
+
+    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/locations/__init__.py b/src/pip/_internal/locations/__init__.py
index dfb5dd360..e5b4b9eec 100644
--- a/src/pip/_internal/locations/__init__.py
+++ b/src/pip/_internal/locations/__init__.py
@@ -4,7 +4,7 @@ import os
 import pathlib
 import sys
 import sysconfig
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Generator, Optional, Tuple
 
 from pip._internal.models.scheme import SCHEME_KEYS, Scheme
 from pip._internal.utils.compat import WINDOWS
@@ -174,6 +174,22 @@ def _looks_like_msys2_mingw_scheme() -> bool:
     )
 
 
+def _fix_abiflags(parts: Tuple[str]) -> Generator[str, None, None]:
+    ldversion = sysconfig.get_config_var("LDVERSION")
+    abiflags = getattr(sys, "abiflags", None)
+
+    # LDVERSION does not end with sys.abiflags. Just return the path unchanged.
+    if not ldversion or not abiflags or not ldversion.endswith(abiflags):
+        yield from parts
+        return
+
+    # Strip sys.abiflags from LDVERSION-based path components.
+    for part in parts:
+        if part.endswith(ldversion):
+            part = part[: (0 - len(abiflags))]
+        yield part
+
+
 @functools.lru_cache(maxsize=None)
 def _warn_mismatched(old: pathlib.Path, new: pathlib.Path, *, key: str) -> None:
     issue_url = "https://github.com/pypa/pip/issues/10151"
diff --git a/src/pip/_internal/models/pylock.py b/src/pip/_internal/models/pylock.py
new file mode 100644
index 000000000..d9decb296
--- /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/_internal/utils/packaging.py b/src/pip/_internal/utils/packaging.py
index 1295b7ffe..5bac550d7 100644
--- a/src/pip/_internal/utils/packaging.py
+++ b/src/pip/_internal/utils/packaging.py
@@ -1,10 +1,13 @@
 import functools
 import logging
-from typing import Optional, Tuple
+import re
+from typing import NewType, Optional, Tuple, cast
 
 from pip._vendor.packaging import specifiers, version
 from pip._vendor.packaging.requirements import Requirement
 
+NormalizedExtra = NewType("NormalizedExtra", str)
+
 logger = logging.getLogger(__name__)
 
 
@@ -41,3 +44,15 @@ def get_requirement(req_string: str) -> Requirement:
     # minimize repeated parsing of the same string to construct equivalent
     # Requirement objects.
     return Requirement(req_string)
+
+
+def safe_extra(extra: str) -> NormalizedExtra:
+    """Convert an arbitrary string to a standard 'extra' name
+
+    Any runs of non-alphanumeric characters are replaced with a single '_',
+    and the result is always lowercased.
+
+    This function is duplicated from ``pkg_resources``. Note that this is not
+    the same to either ``canonicalize_name`` or ``_egg_link_name``.
+    """
+    return cast(NormalizedExtra, re.sub("[^A-Za-z0-9.-]+", "_", extra).lower())
diff --git a/src/pip/_vendor/tomli_w/LICENSE b/src/pip/_vendor/tomli_w/LICENSE
new file mode 100644
index 000000000..e859590f8
--- /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 000000000..6349c1f05
--- /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 000000000..b1acd3f26
--- /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 000000000..7632ecf77
--- /dev/null
+++ b/src/pip/_vendor/tomli_w/py.typed
@@ -0,0 +1,1 @@
+# Marker file for PEP 561
diff --git a/src/pip/_vendor/vendor.txt b/src/pip/_vendor/vendor.txt
index 0c7733ff8..447d3a224 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


Test patch:
diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py
index 366d0129b..6d2e2a129 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 000000000..861fc87b6
--- /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 9d5aefec2..59c0b8821 100644
--- a/tests/unit/test_commands.py
+++ b/tests/unit/test_commands.py
@@ -14,7 +14,14 @@ from pip._internal.commands import commands_dict, create_command
 
 # 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"])