Repository: pdm-project/pdm
Pull request #3482 (2025-04-17 03:20:58)

feat: support exporting to pylock.toml format
Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `72.07207%` with `31 lines` in your changes missing coverage. Please review.
> Project coverage is 85.42%. Comparing base [(`39e082a`)](https://app.codecov.io/gh/pdm-project/pdm/commit/39e082a8cf56798c7c0af5b2001400c7054c4f40?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`4c931a6`)](https://app.codecov.io/gh/pdm-project/pdm/commit/4c931a68df04c0a7034a6312ea2c2794a94941ae?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/formats/pylock.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?src=pr&el=tree&filepath=src%2Fpdm%2Fformats%2Fpylock.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9mb3JtYXRzL3B5bG9jay5weQ==) | 71.57% | [17 Missing and 10 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/cli/commands/export.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?src=pr&el=tree&filepath=src%2Fpdm%2Fcli%2Fcommands%2Fexport.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvZXhwb3J0LnB5) | 71.42% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/models/markers.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?src=pr&el=tree&filepath=src%2Fpdm%2Fmodels%2Fmarkers.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9tb2RlbHMvbWFya2Vycy5weQ==) | 77.77% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3482      +/-   ##
==========================================
- Coverage   85.61%   85.42%   -0.19%     
==========================================
  Files         113      114       +1     
  Lines       11592    11708     +116     
  Branches     2545     2578      +33     
==========================================
+ Hits         9924    10002      +78     
- Misses       1138     1163      +25     
- Partials      530      543      +13     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3482/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/3482/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.24% <72.07%> (-0.18%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/3482?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/docs/usage/lockfile.md b/docs/usage/lockfile.md
index b909e9e5..7b9762c3 100644
--- a/docs/usage/lockfile.md
+++ b/docs/usage/lockfile.md
@@ -247,3 +247,11 @@ pdm export -o requirements.txt
 
 !!! TIP
     You can also run `pdm export` with a [`.pre-commit` hook](./advanced.md#hooks-for-pre-commit).
+
++++ 2.24.0
+
+Additionally, PDM supports exporting to `pylock.toml` format as defined by [PEP 751](https://peps.python.org/pep-0751/). The following command will convert your lock file to a PEP 751 compatible format:
+
+```bash
+pdm export -f pylock -o pylock.toml
+```
diff --git a/news/3480.feature.md b/news/3480.feature.md
new file mode 100644
index 00000000..71954084
--- /dev/null
+++ b/news/3480.feature.md
@@ -0,0 +1,1 @@
+Support exporting to pylock.toml format as described by PEP 751.
diff --git a/src/pdm/cli/commands/export.py b/src/pdm/cli/commands/export.py
index 77ec4adc..ab6020f4 100644
--- a/src/pdm/cli/commands/export.py
+++ b/src/pdm/cli/commands/export.py
@@ -9,6 +9,7 @@ from pdm.cli.filters import GroupSelection
 from pdm.cli.options import groups_group, lockfile_option
 from pdm.exceptions import PdmUsageError
 from pdm.formats import FORMATS
+from pdm.formats.pylock import PyLockConverter
 from pdm.models.candidates import Candidate
 from pdm.models.requirements import Requirement
 from pdm.project import Project
@@ -23,9 +24,9 @@ class Command(BaseCommand):
         parser.add_argument(
             "-f",
             "--format",
-            choices=["requirements"],
+            choices=["requirements", "pylock"],
             default="requirements",
-            help="Only requirements.txt is supported for now.",
+            help="Export to requirements.txt format or pylock.toml format",
         )
         groups_group.add_to_parser(parser)
         parser.add_argument(
@@ -64,6 +65,13 @@ class Command(BaseCommand):
         )
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
+        if options.format == "pylock":
+            doc = PyLockConverter(project).convert()
+            if options.output:
+                Path(options.output).write_text(doc, encoding="utf-8")
+            else:
+                print(doc)
+            return
         if options.pyproject:
             options.hashes = False
         selection = GroupSelection.from_options(project, options)
diff --git a/src/pdm/formats/pylock.py b/src/pdm/formats/pylock.py
new file mode 100644
index 00000000..909deab5
--- /dev/null
+++ b/src/pdm/formats/pylock.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import tomlkit
+from dep_logic.markers import BaseMarker, MarkerUnion, parse_marker
+
+from pdm.exceptions import ProjectError
+from pdm.formats.base import make_array, make_inline_table
+from pdm.models.requirements import FileRequirement, VcsRequirement
+from pdm.project.lockfile import FLAG_INHERIT_METADATA
+from pdm.utils import cd, normalize_name
+
+if TYPE_CHECKING:
+    from pdm.models.markers import Marker
+    from pdm.models.repositories.lock import Package
+    from pdm.project import Project
+
+
+def _group_sort_key(group: str) -> tuple[bool, str]:
+    return group != "default", group
+
+
+class PyLockConverter:
+    def __init__(self, project: Project) -> None:
+        self.project = project
+
+    def make_package(self, package: Package) -> dict[str, Any]:
+        req = package.candidate.req
+        candidate = package.candidate
+        result: dict[str, Any] = {
+            "name": candidate.req.key,
+            "version": candidate.version,
+            "requires-python": candidate.requires_python,
+        }
+        if isinstance(req, VcsRequirement):
+            result["vcs"] = {
+                "type": req.vcs,
+                "url": req.repo,
+                "requested-revision": req.revision,
+                "commit-id": candidate.get_revision(),
+            }
+            if req.subdirectory:
+                result["vcs"]["subdirectory"] = req.subdirectory
+        elif isinstance(req, FileRequirement):
+            if req.is_local_dir:
+                result["directory"] = {
+                    "path": req.str_path,
+                    "editable": req.editable,
+                }
+                if req.subdirectory:
+                    result["directory"]["subdirectory"] = req.subdirectory
+            else:
+                archive: dict[str, Any]
+                archive = result["archive"] = {"url": req.get_full_url()}
+                if req.path:
+                    archive["path"] = req.str_path
+                for hash_item in candidate.hashes:
+                    hash_type, hash_value = hash_item["hash"].split(":", 1)
+                    archive.setdefault("hashes", tomlkit.inline_table())[hash_type] = hash_value
+        else:
+            wheels: list[dict[str, Any]] = tomlkit.array().multiline(True)
+            for hash_item in candidate.hashes:
+                hash_type, hash_value = hash_item["hash"].split(":", 1)
+                if hash_item.get("file", "").endswith(".whl") or hash_item.get("url", "").endswith(".whl"):
+                    wheel: dict[str, Any] = {}
+                    if "file" in hash_item:
+                        wheel["name"] = hash_item["file"]
+                    if "url" in hash_item:
+                        wheel["url"] = hash_item["url"]
+                    wheel["hashes"] = make_inline_table({hash_type: hash_value})
+                    wheels.append(wheel)
+                else:
+                    sdist: dict[str, Any] = {}
+                    if "file" in hash_item:
+                        sdist["name"] = hash_item["file"]
+                    if "url" in hash_item:
+                        sdist["url"] = hash_item["url"]
+                    sdist["hashes"] = make_inline_table({hash_type: hash_value})
+                    result["sdist"] = sdist
+            if wheels:
+                result["wheels"] = wheels
+
+        return result
+
+    def convert(self) -> str:
+        doc = tomlkit.document()
+        project = self.project
+        lockfile = project.lockfile
+        if FLAG_INHERIT_METADATA not in lockfile.strategy:
+            raise ProjectError("inherit_metadata strategy is required for pylock format")
+        repository = project.get_locked_repository()
+        all_groups = lockfile.groups
+        if all_groups is None:
+            all_groups = list(project.iter_groups())
+        extras: list[str] = []
+        groups: list[str] = []
+        optional_groups = {
+            normalize_name(group) for group in project.pyproject.metadata.get("optional-dependencies", [])
+        }
+        for group in all_groups:
+            if group in optional_groups:
+                extras.append(group)
+            else:
+                groups.append(group)
+        env_markers: list[Marker] = []
+        for target in repository.targets:
+            env_markers.append(target.markers_with_python())
+
+        doc.update(
+            {
+                "lock-version": "1.0",
+                "requires-python": str(project.python_requires),
+                "environments": make_array([str(marker) for marker in env_markers], multiline=True),
+                "extras": extras,
+                "dependency-groups": groups,
+                "default-groups": ["default"],
+                "created-by": "pdm",
+            }
+        )
+        packages = doc.setdefault("packages", tomlkit.aot())
+
+        with cd(project.root):
+            for package in repository.packages.values():
+                if package.candidate.req.extras:
+                    continue
+                package_table = self.make_package(package)
+                selection_markers: list[BaseMarker] = []
+                for item in sorted(package.candidate.req.groups, key=_group_sort_key):
+                    item = normalize_name(item)
+                    if item in extras:
+                        selection_markers.append(parse_marker(f"'{item}' in extras"))
+                    else:
+                        selection_markers.append(parse_marker(f"'{item}' in dependency_groups"))
+                marker = MarkerUnion.of(*selection_markers)
+                if package.candidate.req.marker is not None:
+                    marker = package.candidate.req.marker.inner & marker
+                if not marker.is_any():
+                    package_table["marker"] = str(marker)
+                packages.append(package_table)
+
+        doc["tool"] = {"pdm": {"hashes": make_inline_table({"sha256": project.pyproject.content_hash()})}}
+
+        return tomlkit.dumps(doc)
diff --git a/src/pdm/models/markers.py b/src/pdm/models/markers.py
index d13882fc..cc97efa6 100644
--- a/src/pdm/models/markers.py
+++ b/src/pdm/models/markers.py
@@ -168,5 +168,15 @@ class EnvSpec(_EnvSpec):
 
         return {**default_environment(), **self.markers()}  # type: ignore[dict-item]
 
+    def markers_with_python(self) -> Marker:
+        env = self.markers()
+        markers: list[BaseMarker] = []
+        env.pop("python_version", None)
+        env.pop("python_full_version", None)
+        markers.append(parse_marker(PySpecSet(self.requires_python).as_marker_string()))
+        for key, value in env.items():
+            markers.append(parse_marker(f'{key} == "{value}"'))
+        return Marker(MultiMarker.of(*markers))
+
     def is_allow_all(self) -> bool:
         return self.platform is None and self.implementation is None


Test patch:
diff --git a/tests/fixtures/projects/demo/pdm.lock b/tests/fixtures/projects/demo/pdm.lock
new file mode 100644
index 00000000..bd5e4b56
--- /dev/null
+++ b/tests/fixtures/projects/demo/pdm.lock
@@ -0,0 +1,137 @@
+# This file is @generated by PDM.
+# It is not intended for manual editing.
+
+[metadata]
+groups = ["default", "security", "tests"]
+strategy = ["inherit_metadata"]
+lock_version = "4.5.0"
+content_hash = "sha256:2584886ac58a0ae70aa36bc0318b62c3e2c89acc9c21ebb9aee74147c0a9dc06"
+
+[[metadata.targets]]
+requires_python = ">=3.3"
+
+[[package]]
+name = "certifi"
+version = "2025.1.31"
+requires_python = ">=3.6"
+summary = "Python package for providing Mozilla's CA Bundle."
+groups = ["security"]
+marker = "python_version >= \"3.6\""
+files = [
+    {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
+    {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
+]
+
+[[package]]
+name = "chardet"
+version = "3.0.4"
+summary = "Universal encoding detector for Python 2 and 3"
+groups = ["default"]
+marker = "os_name == \"nt\""
+files = [
+    {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
+    {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "2.0.12"
+requires_python = ">=3.5.0"
+summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+groups = ["security"]
+marker = "python_version >= \"3.6\""
+files = [
+    {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
+    {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.3.9"
+summary = "Cross-platform colored terminal text."
+groups = ["tests"]
+marker = "sys_platform == \"win32\""
+files = [
+    {file = "colorama-0.3.9-py2.py3-none-any.whl", hash = "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda"},
+    {file = "colorama-0.3.9.tar.gz", hash = "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"},
+]
+
+[[package]]
+name = "idna"
+version = "2.7"
+summary = "Internationalized Domain Names in Applications (IDNA)"
+groups = ["default", "security"]
+files = [
+    {file = "idna-2.7-py2.py3-none-any.whl", hash = "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e"},
+    {file = "idna-2.7.tar.gz", hash = "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"},
+]
+
+[[package]]
+name = "py"
+version = "1.4.34"
+summary = "library with cross-python path, ini-parsing, io, code, log facilities"
+groups = ["tests"]
+files = [
+    {file = "py-1.4.34-py2.py3-none-any.whl", hash = "sha256:2ccb79b01769d99115aa600d7eed99f524bf752bba8f041dc1c184853514655a"},
+    {file = "py-1.4.34.tar.gz", hash = "sha256:0f2d585d22050e90c7d293b6451c83db097df77871974d90efd5a30dc12fcde3"},
+]
+
+[[package]]
+name = "pytest"
+version = "3.2.5"
+summary = "pytest: simple powerful testing with Python"
+groups = ["tests"]
+dependencies = [
+    "argparse; python_version == \"2.6\"",
+    "colorama; sys_platform == \"win32\"",
+    "ordereddict; python_version == \"2.6\"",
+    "py>=1.4.33",
+    "setuptools",
+]
+files = [
+    {file = "pytest-3.2.5-py2.py3-none-any.whl", hash = "sha256:241d7e7798d79192a123ceaf64c602b4d233eacf6d6e42ae27caa97f498b7dc6"},
+    {file = "pytest-3.2.5.tar.gz", hash = "sha256:6d5bd4f7113b444c55a3bbb5c738a3dd80d43563d063fc42dcb0aaefbdd78b81"},
+]
+
+[[package]]
+name = "requests"
+version = "2.27.1"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+summary = "Python HTTP for Humans."
+groups = ["security"]
+marker = "python_version >= \"3.6\""
+dependencies = [
+    "certifi>=2017.4.17",
+    "chardet<5,>=3.0.2; python_version < \"3\"",
+    "charset-normalizer~=2.0.0; python_version >= \"3\"",
+    "idna<3,>=2.5; python_version < \"3\"",
+    "idna<4,>=2.5; python_version >= \"3\"",
+    "urllib3<1.27,>=1.21.1",
+]
+files = [
+    {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
+    {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
+]
+
+[[package]]
+name = "setuptools"
+version = "39.2.0"
+requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*"
+summary = "Easily download, build, install, upgrade, and uninstall Python packages"
+groups = ["tests"]
+files = [
+    {file = "setuptools-39.2.0-py2.py3-none-any.whl", hash = "sha256:8fca9275c89964f13da985c3656cb00ba029d7f3916b37990927ffdf264e7926"},
+    {file = "setuptools-39.2.0.zip", hash = "sha256:f7cddbb5f5c640311eb00eab6e849f7701fa70bf6a183fc8a2c33dd1d1672fb2"},
+]
+
+[[package]]
+name = "urllib3"
+version = "1.26.20"
+requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+summary = "HTTP library with thread-safe connection pooling, file post, and more."
+groups = ["security"]
+marker = "python_version >= \"3.6\""
+files = [
+    {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"},
+    {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"},
+]
diff --git a/tests/fixtures/projects/demo/pdm.no_groups.lock b/tests/fixtures/projects/demo/pdm.no_groups.lock
new file mode 100644
index 00000000..581bc10b
--- /dev/null
+++ b/tests/fixtures/projects/demo/pdm.no_groups.lock
@@ -0,0 +1,121 @@
+# This file is @generated by PDM.
+# It is not intended for manual editing.
+
+[metadata]
+groups = ["default", "security", "tests"]
+strategy = []
+lock_version = "4.5.0"
+content_hash = "sha256:2584886ac58a0ae70aa36bc0318b62c3e2c89acc9c21ebb9aee74147c0a9dc06"
+
+[[metadata.targets]]
+requires_python = ">=3.3"
+
+[[package]]
+name = "certifi"
+version = "2025.1.31"
+requires_python = ">=3.6"
+summary = "Python package for providing Mozilla's CA Bundle."
+files = [
+    {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
+    {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
+]
+
+[[package]]
+name = "chardet"
+version = "3.0.4"
+summary = "Universal encoding detector for Python 2 and 3"
+files = [
+    {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
+    {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "2.0.12"
+requires_python = ">=3.5.0"
+summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+files = [
+    {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
+    {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.3.9"
+summary = "Cross-platform colored terminal text."
+files = [
+    {file = "colorama-0.3.9-py2.py3-none-any.whl", hash = "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda"},
+    {file = "colorama-0.3.9.tar.gz", hash = "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"},
+]
+
+[[package]]
+name = "idna"
+version = "2.7"
+summary = "Internationalized Domain Names in Applications (IDNA)"
+files = [
+    {file = "idna-2.7-py2.py3-none-any.whl", hash = "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e"},
+    {file = "idna-2.7.tar.gz", hash = "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"},
+]
+
+[[package]]
+name = "py"
+version = "1.4.34"
+summary = "library with cross-python path, ini-parsing, io, code, log facilities"
+files = [
+    {file = "py-1.4.34-py2.py3-none-any.whl", hash = "sha256:2ccb79b01769d99115aa600d7eed99f524bf752bba8f041dc1c184853514655a"},
+    {file = "py-1.4.34.tar.gz", hash = "sha256:0f2d585d22050e90c7d293b6451c83db097df77871974d90efd5a30dc12fcde3"},
+]
+
+[[package]]
+name = "pytest"
+version = "3.2.5"
+summary = "pytest: simple powerful testing with Python"
+dependencies = [
+    "argparse; python_version == \"2.6\"",
+    "colorama; sys_platform == \"win32\"",
+    "ordereddict; python_version == \"2.6\"",
+    "py>=1.4.33",
+    "setuptools",
+]
+files = [
+    {file = "pytest-3.2.5-py2.py3-none-any.whl", hash = "sha256:241d7e7798d79192a123ceaf64c602b4d233eacf6d6e42ae27caa97f498b7dc6"},
+    {file = "pytest-3.2.5.tar.gz", hash = "sha256:6d5bd4f7113b444c55a3bbb5c738a3dd80d43563d063fc42dcb0aaefbdd78b81"},
+]
+
+[[package]]
+name = "requests"
+version = "2.27.1"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+summary = "Python HTTP for Humans."
+dependencies = [
+    "certifi>=2017.4.17",
+    "chardet<5,>=3.0.2; python_version < \"3\"",
+    "charset-normalizer~=2.0.0; python_version >= \"3\"",
+    "idna<3,>=2.5; python_version < \"3\"",
+    "idna<4,>=2.5; python_version >= \"3\"",
+    "urllib3<1.27,>=1.21.1",
+]
+files = [
+    {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
+    {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
+]
+
+[[package]]
+name = "setuptools"
+version = "39.2.0"
+requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*"
+summary = "Easily download, build, install, upgrade, and uninstall Python packages"
+files = [
+    {file = "setuptools-39.2.0-py2.py3-none-any.whl", hash = "sha256:8fca9275c89964f13da985c3656cb00ba029d7f3916b37990927ffdf264e7926"},
+    {file = "setuptools-39.2.0.zip", hash = "sha256:f7cddbb5f5c640311eb00eab6e849f7701fa70bf6a183fc8a2c33dd1d1672fb2"},
+]
+
+[[package]]
+name = "urllib3"
+version = "1.26.20"
+requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+summary = "HTTP library with thread-safe connection pooling, file post, and more."
+files = [
+    {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"},
+    {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"},
+]
diff --git a/tests/fixtures/projects/demo/pylock.toml b/tests/fixtures/projects/demo/pylock.toml
new file mode 100644
index 00000000..4638f32b
--- /dev/null
+++ b/tests/fixtures/projects/demo/pylock.toml
@@ -0,0 +1,141 @@
+lock-version = "1.0"
+requires-python = ">=3.3"
+environments = [
+    "python_version >= \"3.3\"",
+]
+extras = ["security", "tests"]
+dependency-groups = ["default"]
+default-groups = ["default"]
+created-by = "pdm"
+[[packages]]
+name = "certifi"
+version = "2025.1.31"
+requires-python = ">=3.6"
+wheels = [
+    {name = "certifi-2025.1.31-py3-none-any.whl",hashes = {sha256 = "ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}},
+]
+marker = "python_version >= \"3.6\" and \"security\" in extras"
+
+[packages.sdist]
+name = "certifi-2025.1.31.tar.gz"
+hashes = {sha256 = "3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}
+
+[[packages]]
+name = "chardet"
+version = "3.0.4"
+requires-python = ""
+wheels = [
+    {name = "chardet-3.0.4-py2.py3-none-any.whl",hashes = {sha256 = "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}},
+]
+marker = "os_name == \"nt\" and \"default\" in dependency_groups"
+
+[packages.sdist]
+name = "chardet-3.0.4.tar.gz"
+hashes = {sha256 = "84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}
+
+[[packages]]
+name = "charset-normalizer"
+version = "2.0.12"
+requires-python = ">=3.5.0"
+wheels = [
+    {name = "charset_normalizer-2.0.12-py3-none-any.whl",hashes = {sha256 = "6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}},
+]
+marker = "python_version >= \"3.6\" and \"security\" in extras"
+
+[packages.sdist]
+name = "charset-normalizer-2.0.12.tar.gz"
+hashes = {sha256 = "2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}
+
+[[packages]]
+name = "colorama"
+version = "0.3.9"
+requires-python = ""
+wheels = [
+    {name = "colorama-0.3.9-py2.py3-none-any.whl",hashes = {sha256 = "463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda"}},
+]
+marker = "sys_platform == \"win32\" and \"tests\" in extras"
+
+[packages.sdist]
+name = "colorama-0.3.9.tar.gz"
+hashes = {sha256 = "48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"}
+
+[[packages]]
+name = "idna"
+version = "2.7"
+requires-python = ""
+wheels = [
+    {name = "idna-2.7-py2.py3-none-any.whl",hashes = {sha256 = "156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e"}},
+]
+marker = "\"default\" in dependency_groups or \"security\" in extras"
+
+[packages.sdist]
+name = "idna-2.7.tar.gz"
+hashes = {sha256 = "684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"}
+
+[[packages]]
+name = "py"
+version = "1.4.34"
+requires-python = ""
+wheels = [
+    {name = "py-1.4.34-py2.py3-none-any.whl",hashes = {sha256 = "2ccb79b01769d99115aa600d7eed99f524bf752bba8f041dc1c184853514655a"}},
+]
+marker = "\"tests\" in extras"
+
+[packages.sdist]
+name = "py-1.4.34.tar.gz"
+hashes = {sha256 = "0f2d585d22050e90c7d293b6451c83db097df77871974d90efd5a30dc12fcde3"}
+
+[[packages]]
+name = "pytest"
+version = "3.2.5"
+requires-python = ""
+wheels = [
+    {name = "pytest-3.2.5-py2.py3-none-any.whl",hashes = {sha256 = "241d7e7798d79192a123ceaf64c602b4d233eacf6d6e42ae27caa97f498b7dc6"}},
+]
+marker = "\"tests\" in extras"
+
+[packages.sdist]
+name = "pytest-3.2.5.tar.gz"
+hashes = {sha256 = "6d5bd4f7113b444c55a3bbb5c738a3dd80d43563d063fc42dcb0aaefbdd78b81"}
+
+[[packages]]
+name = "requests"
+version = "2.27.1"
+requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+wheels = [
+    {name = "requests-2.27.1-py2.py3-none-any.whl",hashes = {sha256 = "f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}},
+]
+marker = "python_version >= \"3.6\" and \"security\" in extras"
+
+[packages.sdist]
+name = "requests-2.27.1.tar.gz"
+hashes = {sha256 = "68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}
+
+[[packages]]
+name = "setuptools"
+version = "39.2.0"
+requires-python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*"
+wheels = [
+    {name = "setuptools-39.2.0-py2.py3-none-any.whl",hashes = {sha256 = "8fca9275c89964f13da985c3656cb00ba029d7f3916b37990927ffdf264e7926"}},
+]
+marker = "\"tests\" in extras"
+
+[packages.sdist]
+name = "setuptools-39.2.0.zip"
+hashes = {sha256 = "f7cddbb5f5c640311eb00eab6e849f7701fa70bf6a183fc8a2c33dd1d1672fb2"}
+
+[[packages]]
+name = "urllib3"
+version = "1.26.20"
+requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+wheels = [
+    {name = "urllib3-1.26.20-py2.py3-none-any.whl",hashes = {sha256 = "0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}},
+]
+marker = "python_version >= \"3.6\" and \"security\" in extras"
+
+[packages.sdist]
+name = "urllib3-1.26.20.tar.gz"
+hashes = {sha256 = "40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}
+
+[tool.pdm]
+hashes = {sha256 = "2584886ac58a0ae70aa36bc0318b62c3e2c89acc9c21ebb9aee74147c0a9dc06"}
diff --git a/tests/test_formats.py b/tests/test_formats.py
index 88c12f45..c5c4b168 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -249,3 +249,14 @@ def test_convert_poetry_project_with_circular_dependency(project):
 
     _, settings = poetry.convert(project, child_file, ns())
     assert settings["dev-dependencies"]["dev"] == ["parent @ file:///${PROJECT_ROOT}/../.."]
+
+
+def test_export_pylock_toml(core, pdm):
+    project = core.create_project(FIXTURES / "projects/demo")
+    golden_file = FIXTURES / "projects/demo/pylock.toml"
+    result = pdm(["export", "-f", "pylock"], obj=project, strict=True)
+    assert result.stdout.strip() == golden_file.read_text(encoding="utf-8").strip()
+    with cd(project.root):
+        result = pdm(["export", "-f", "pylock", "-L", "pdm.no_groups.lock"])
+    assert result.exit_code == 1
+    assert "inherit_metadata strategy is required for pylock format" in result.stderr