Repository: pdm-project/pdm
Pull request #3530 (2025-06-04 08:41:52)

feat: Support pylock.toml and installing from it
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/3530?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 `73.31190%` with `83 lines` in your changes missing coverage. Please review.
> Project coverage is 84.30%. Comparing base [(`c0fb96d`)](https://app.codecov.io/gh/pdm-project/pdm/commit/c0fb96d2442b6ab60671098f69d2079b23dcfb68?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`26797a3`)](https://app.codecov.io/gh/pdm-project/pdm/commit/26797a3232e6a272b8d3991b4d3933a0b7df06db?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Report is 3 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?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/models/repositories/lock.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fmodels%2Frepositories%2Flock.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9tb2RlbHMvcmVwb3NpdG9yaWVzL2xvY2sucHk=) | 45.28% | [21 Missing and 8 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?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/3530?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==) | 43.24% | [20 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/project/lockfile/pdmlock.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Flockfile%2Fpdmlock.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2xvY2tmaWxlL3BkbWxvY2sucHk=) | 87.05% | [5 Missing and 6 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/project/lockfile/pylock.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Flockfile%2Fpylock.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2xvY2tmaWxlL3B5bG9jay5weQ==) | 78.37% | [7 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/project/lockfile/base.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Flockfile%2Fbase.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2xvY2tmaWxlL2Jhc2UucHk=) | 80.76% | [5 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/project/lockfile/\_\_init\_\_.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Flockfile%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2xvY2tmaWxlL19faW5pdF9fLnB5) | 82.60% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/project/core.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2NvcmUucHk=) | 86.36% | [1 Missing and 2 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?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/fix/fixers.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?src=pr&el=tree&filepath=src%2Fpdm%2Fcli%2Fcommands%2Ffix%2Ffixers.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvZml4L2ZpeGVycy5weQ==) | 66.66% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3530?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    #3530      +/-   ##
==========================================
- Coverage   85.20%   84.30%   -0.91%     
==========================================
  Files         115      118       +3     
  Lines       11768    11985     +217     
  Branches     2591     1966     -625     
==========================================
+ Hits        10027    10104      +77     
- Misses       1190     1308     +118     
- Partials      551      573      +22     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3530/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/3530/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `84.24% <72.99%> (-0.78%)` | :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/3530?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 cc6d9116..b5f921f6 100644
--- a/docs/usage/lockfile.md
+++ b/docs/usage/lockfile.md
@@ -45,6 +45,20 @@ pdm lock --refresh
 
 This command also refreshes _all_ file hashes recorded in the lock file.
 
+## Change lock file format
+
+PDM supports two lock file formats: `pdm`(default file name is `pdm.lock`) and `pylock`(default file name is `pylock.toml`). The default format is `pdm`.
+
++++ 2.25.0
+
+    Added experimental support for the [PEP 751](https://packaging.python.org/en/latest/specifications/pylock-toml/#pylock-toml-spec) pylock file format. It's a standard lock file format designed to minimize discrepancies among different Python package managers, enhancing interoperability with other tools. It is set to become the default in a future version of PDM. Read the specification for more details.
+
+You can switch to the `pylock` format with `pdm config` command:
+
+```bash
+pdm config lock.format pylock
+```
+
 ## Specify another lock file to use
 
 By default, PDM uses `pdm.lock` in the current directory. You can specify another lock file with the `-L/--lockfile` option or the `PDM_LOCKFILE` environment variable:
@@ -250,7 +264,7 @@ pdm export -o requirements.txt
 
 +++ 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:
+Additionally, PDM supports exporting to `pylock.toml` format as defined by [PEP 751](https://packaging.python.org/en/latest/specifications/pylock-toml/#pylock-toml-spec). 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/3481.feature.md b/news/3481.feature.md
new file mode 100644
index 00000000..344422cc
--- /dev/null
+++ b/news/3481.feature.md
@@ -0,0 +1,1 @@
+Support pylock as alternative lock format and make it opt-in by config.
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index f9629c35..3e1f9721 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -72,8 +72,8 @@ def do_lock(
                 for c in candidates:
                     c.hashes.clear()
                 repo.fetch_hashes(candidates)
-            lockfile = locked_repo.format_lockfile(groups=project.lockfile.groups, strategy=lock_strategy)
-        project.write_lockfile(lockfile)
+            project.lockfile.format_lockfile(locked_repo, groups=project.lockfile.groups, strategy=lock_strategy)
+        project.write_lockfile()
         return locked_repo.all_candidates
 
     if groups is None:
@@ -153,10 +153,10 @@ def do_lock(
                 raise ResolutionError("Unable to find a resolution") from None
             else:
                 groups = list(set(groups) | collected_groups)
-                data = result_repo.format_lockfile(groups=groups, strategy=lock_strategy)
                 if project.enable_write_lockfile:
                     reporter.update(f"{termui.Emoji.LOCK} Lock successful.", info="", completed=1)
-                project.write_lockfile(data, write=not dry_run)
+                project.lockfile.format_lockfile(result_repo, groups=groups, strategy=lock_strategy)
+                project.write_lockfile(write=not dry_run)
                 hooks.try_emit("post_lock", resolution=result_repo.all_candidates, dry_run=dry_run)
 
     return result_repo.all_candidates
@@ -241,7 +241,7 @@ def resolve_candidates_from_lockfile(
 
 def check_lockfile(project: Project, raise_not_exist: bool = True) -> str | None:
     """Check if the lock file exists and is up to date. Return the lock strategy."""
-    from pdm.project.lockfile import Compatibility
+    from pdm.project.lockfile.base import Compatibility
 
     if not project.lockfile.exists():
         if raise_not_exist:
diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py
index 3f8283cb..6c607a66 100644
--- a/src/pdm/cli/commands/add.py
+++ b/src/pdm/cli/commands/add.py
@@ -171,7 +171,7 @@ class Command(BaseCommand):
         save_version_specifiers(deps_to_update, resolved, save)
         if not dry_run:
             project.add_dependencies(deps_to_update, group, selection.dev or False)
-            project.write_lockfile(project.lockfile._data, False)
+            project.write_lockfile(show_message=False)
         hooks.try_emit("post_lock", resolution=resolved, dry_run=dry_run)
         if sync:
             do_sync(
diff --git a/src/pdm/cli/commands/export.py b/src/pdm/cli/commands/export.py
index 5b7ee102..24de447b 100644
--- a/src/pdm/cli/commands/export.py
+++ b/src/pdm/cli/commands/export.py
@@ -4,6 +4,8 @@ import argparse
 from pathlib import Path
 from typing import Iterable
 
+import tomlkit
+
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.options import groups_group, lockfile_option
@@ -73,7 +75,7 @@ class Command(BaseCommand):
                 locked_repository.add_package(
                     Package(project.make_self_candidate(editable=options.editable_self), [], "")
                 )
-            doc = PyLockConverter(project, locked_repository).convert()
+            doc = tomlkit.dumps(PyLockConverter(project, locked_repository).convert())
             if options.output:
                 Path(options.output).write_text(doc, encoding="utf-8")
             else:
diff --git a/src/pdm/cli/commands/fix/fixers.py b/src/pdm/cli/commands/fix/fixers.py
index 46bcd871..6bbaf1b4 100644
--- a/src/pdm/cli/commands/fix/fixers.py
+++ b/src/pdm/cli/commands/fix/fixers.py
@@ -108,10 +108,15 @@ class LockStrategyFixer(BaseFixer):
         return "Lock strategy [success]`cross_platform`[/] has been deprecated in favor of lock targets."
 
     def check(self) -> bool:
-        lockfile_version = self.project.lockfile.file_version
+        from pdm.project.lockfile import PDMLock
+
+        lockfile = self.project.lockfile
+        if not isinstance(lockfile, PDMLock):  # pragma: no cover
+            return False
+        lockfile_version = lockfile.file_version
         if not lockfile_version or parse_version(lockfile_version) < parse_version("4.5.0"):
             return False
-        return FLAG_CROSS_PLATFORM in self.project.lockfile.strategy
+        return FLAG_CROSS_PLATFORM in lockfile.strategy
 
     def fix(self) -> None:
         strategies = self.project.lockfile.strategy - {FLAG_CROSS_PLATFORM}
diff --git a/src/pdm/cli/commands/update.py b/src/pdm/cli/commands/update.py
index 02c49f0a..75ce757a 100644
--- a/src/pdm/cli/commands/update.py
+++ b/src/pdm/cli/commands/update.py
@@ -192,7 +192,7 @@ class Command(BaseCommand):
             if unconstrained:
                 for group, deps in updated_deps.items():
                     project.add_dependencies(deps, group, selection.dev or False)
-            project.write_lockfile(project.lockfile._data, False)
+            project.write_lockfile(show_message=False)
         hooks.try_emit("post_lock", resolution=resolved, dry_run=dry_run)
         if sync or dry_run:
             do_sync(
diff --git a/src/pdm/formats/pylock.py b/src/pdm/formats/pylock.py
index 7124fa39..284f30e7 100644
--- a/src/pdm/formats/pylock.py
+++ b/src/pdm/formats/pylock.py
@@ -23,6 +23,8 @@ def _group_sort_key(group: str) -> tuple[bool, str]:
 
 
 class PyLockConverter:
+    lock_version = "1.0"
+
     def __init__(self, project: Project, locked_repository: LockedRepository) -> None:
         self.project = project
         self.locked_repository = locked_repository
@@ -101,33 +103,25 @@ class PyLockConverter:
                 repo = self.project.get_repository()
                 repo.fetch_hashes(candidates)
 
-    def convert(self) -> str:
+    def convert(self, all_groups: Iterable[str] | None = None) -> dict[str, Any]:
         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 = self.locked_repository
-        all_groups = lockfile.groups
+        if all_groups is None:
+            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)
+        extras, groups = self.project.split_extras_groups(list(all_groups))
         env_markers: list[Marker] = []
         for target in repository.targets:
             env_markers.append(target.markers_with_python())
 
         doc.update(
             {
-                "lock-version": "1.0",
+                "lock-version": self.lock_version,
                 "requires-python": str(project.python_requires),
                 "environments": make_array([str(marker) for marker in env_markers], multiline=True),
                 "extras": extras,
@@ -158,6 +152,11 @@ class PyLockConverter:
                     package_table["marker"] = str(marker)
                 packages.append(package_table)
 
-        doc["tool"] = {"pdm": {"hashes": make_inline_table({"sha256": project.pyproject.content_hash()})}}
+        doc["tool"] = {
+            "pdm": {
+                "hashes": make_inline_table({"sha256": project.pyproject.content_hash()}),
+                "targets": [spec.as_dict() for spec in repository.targets],
+            },
+        }
 
-        return tomlkit.dumps(doc)
+        return doc
diff --git a/src/pdm/models/markers.py b/src/pdm/models/markers.py
index cc97efa6..ff924774 100644
--- a/src/pdm/models/markers.py
+++ b/src/pdm/models/markers.py
@@ -15,6 +15,7 @@ from dep_logic.markers import (
     parse_marker,
 )
 from dep_logic.tags import EnvSpec as _EnvSpec
+from dep_logic.tags import Implementation, Platform
 from packaging.markers import Marker as PackageMarker
 
 from pdm.exceptions import RequirementError
@@ -31,7 +32,7 @@ IMPLEMENTATION_MARKERS = frozenset({"implementation_name", "implementation_versi
 PYTHON_MARKERS = frozenset({"python_version", "python_full_version"})
 
 
-def _exclude_multi(marker: Marker, *names: str) -> Marker:
+def exclude_multi(marker: Marker, *names: str) -> Marker:
     inner = marker.inner
     for name in names:
         inner = inner.exclude(name)
@@ -67,13 +68,18 @@ class Marker:
     def evaluate(self, environment: dict[str, Any] | None = None) -> bool:
         return self.inner.evaluate(environment)
 
-    def matches(self, spec: EnvSpec) -> bool:
+    def matches(self, spec: EnvSpec, extras: list[str] | None = None, groups: list[str] | None = None) -> bool:
         non_python_marker, python_spec = self.split_pyspec()
         if spec.platform is None:
-            non_python_marker = _exclude_multi(non_python_marker, *PLATFORM_MARKERS)
+            non_python_marker = exclude_multi(non_python_marker, *PLATFORM_MARKERS)
         if spec.implementation is None:
-            non_python_marker = _exclude_multi(non_python_marker, *IMPLEMENTATION_MARKERS)
-        return not (python_spec & spec.requires_python).is_empty() and non_python_marker.evaluate(spec.markers())
+            non_python_marker = exclude_multi(non_python_marker, *IMPLEMENTATION_MARKERS)
+        env_dict = spec.markers()
+        if extras is not None:
+            env_dict["extras"] = set(extras)
+        if groups is not None:
+            env_dict["dependency_groups"] = set(groups)
+        return not (python_spec & spec.requires_python).is_empty() and non_python_marker.evaluate(env_dict)
 
     @lru_cache(maxsize=1024)
     def split_pyspec(self) -> tuple[Marker, PySpecSet]:
@@ -81,7 +87,7 @@ class Marker:
         python_marker = self.inner.only(*PYTHON_MARKERS)
         if python_marker.is_any():
             return self, PySpecSet()
-        new_marker = _exclude_multi(self, *PYTHON_MARKERS)
+        new_marker = exclude_multi(self, *PYTHON_MARKERS)
         return new_marker, _build_pyspec_from_marker(python_marker)
 
     def split_extras(self) -> tuple[Marker, Marker]:
@@ -153,8 +159,6 @@ def _build_pyspec_from_marker(marker: BaseMarker) -> PySpecSet:
 
 class EnvSpec(_EnvSpec):
     def replace(self, **kwargs: Any) -> Self:
-        from dep_logic.tags import Implementation, Platform
-
         if "requires_python" in kwargs:
             kwargs["requires_python"] = cast(PySpecSet, kwargs["requires_python"])._logic
         if "platform" in kwargs:
@@ -168,9 +172,43 @@ class EnvSpec(_EnvSpec):
 
         return {**default_environment(), **self.markers()}  # type: ignore[dict-item]
 
+    @classmethod
+    def from_marker(cls, marker: Marker) -> Self:  # pragma: no cover
+        """Create an EnvSpec from a Marker object."""
+        marker_no_python, pyspec = marker.split_pyspec()
+        kwargs = {"requires_python": str(pyspec)}
+        if not marker_no_python.is_any() and not isinstance(marker_no_python.inner, (MultiMarker, MarkerExpression)):
+            raise TypeError(
+                f"Unsupported environment marker: {marker}, not a single expression or connected with 'and'"
+            )
+        if not (implementation_marker := marker_no_python.inner.only("implementation_name")).is_any():
+            assert isinstance(implementation_marker, MarkerExpression)
+            kwargs["implementation"] = implementation_marker.value
+        if not (platform_marker := marker_no_python.inner.only(*PLATFORM_MARKERS)).is_any():
+            platform = platform_marker.only("platform_system")
+            arch = platform_marker.only("platform_machine")
+            if not all(isinstance(m, MarkerExpression) for m in (platform, arch)):
+                raise TypeError(f"Unsupported platform marker: {platform_marker}")
+            os = platform.value.lower()
+            if os == "linux":
+                os = "manylinux_2_17"
+            elif os == "darwin":
+                os = "macos"
+            kwargs["platform"] = f"{os}_{arch.value.lower()}"
+        return cls.from_spec(**kwargs)
+
     def markers_with_python(self) -> Marker:
         env = self.markers()
+        if self.platform is not None:  # pragma: no cover
+            env.update(
+                sys_platform=self.platform.sys_platform,
+                platform_system=self.platform.platform_system,
+                os_name=self.platform.os_name,
+                platform_machine=self.platform.platform_machine,
+            )
         markers: list[BaseMarker] = []
+        env.pop("platform_release", None)
+        env.pop("platform_version", None)
         env.pop("python_version", None)
         env.pop("python_full_version", None)
         markers.append(parse_marker(PySpecSet(self.requires_python).as_marker_string()))
diff --git a/src/pdm/models/repositories/lock.py b/src/pdm/models/repositories/lock.py
index 5c1ffc5a..e22910d9 100644
--- a/src/pdm/models/repositories/lock.py
+++ b/src/pdm/models/repositories/lock.py
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Collection, NamedTuple, cast
 
 from pdm.exceptions import CandidateNotFound, PdmException
 from pdm.models.candidates import Candidate
-from pdm.models.markers import EnvSpec
+from pdm.models.markers import EnvSpec, exclude_multi, get_marker
 from pdm.models.repositories.base import BaseRepository, CandidateMetadata
 from pdm.models.requirements import FileRequirement, Requirement, parse_line
 from pdm.utils import cd, url_to_path, url_without_fragments
@@ -16,8 +16,6 @@ from pdm.utils import cd, url_to_path, url_without_fragments
 if TYPE_CHECKING:
     from typing import Any, Callable, Iterable, Mapping
 
-    from tomlkit.toml_document import TOMLDocument
-
     from pdm._types import FileHash, RepositoryConfig
     from pdm.environments import BaseEnvironment
 
@@ -60,12 +58,71 @@ class LockedRepository(BaseRepository):
         result: dict[str, Candidate] = {}
         for candidates in self.all_candidates.values():
             for can in candidates:
-                if can.req.marker and not can.req.marker.matches(self.env_spec):
-                    continue
+                if can.req.marker:
+                    marker = exclude_multi(can.req.marker, "extras", "dependency_groups")
+                    if not marker.matches(self.env_spec):
+                        continue
                 result[can.identify()] = can
         return result
 
     def _read_lockfile(self, lockfile: Mapping[str, Any]) -> None:
+        if lockfile.get("lock-version"):
+            return self._read_pylock(lockfile)
+        else:
+            return self._read_pdm_lock(lockfile)
+
+    def _read_pylock(self, lockfile: Mapping[str, Any]) -> None:
+        root = self.environment.project.root
+
+        if "targets" in lockfile.get("tool", {}).get("pdm", {}):
+            self.targets = [EnvSpec.from_spec(**t) for t in lockfile["tool"]["pdm"]["targets"]]
+        else:
+            for marker in lockfile.get("environments", []):  # pragma: no cover
+                self.targets.append(EnvSpec.from_marker(get_marker(cast(str, marker))))
+
+        with cd(root):
+            for package in lockfile.get("packages", []):
+                package_name = package.pop("name")
+                req_dict: dict[str, str | bool] = {}
+                if "version" in package:
+                    req_dict["version"] = f"=={package['version']}"
+                if "marker" in package:
+                    req_dict["marker"] = package["marker"]
+                if vcs := package.get("vcs"):  # pragma: no cover
+                    req_dict[vcs["type"]] = vcs["url"]
+                    req_dict["ref"] = vcs.get("requested-revision")
+                    req_dict["revision"] = vcs.get("commit-id")
+                    req_dict["subdirectory"] = vcs.get("subdirectory")
+                elif directory := package.get("directory"):  # pragma: no cover
+                    req_dict["path"] = directory["path"]
+                    req_dict["editable"] = directory.get("editable", False)
+                    req_dict["subdirectory"] = directory.get("subdirectory")
+                elif archive := package.get("archive"):  # pragma: no cover
+                    req_dict["url"] = archive.get("url")
+                    req_dict["path"] = archive.get("path")
+                    req_dict["subdirectory"] = archive.get("subdirectory")
+                candidate = Candidate(
+                    req=Requirement.from_req_dict(package_name, req_dict),
+                    name=package_name,
+                    version=package.get("version"),
+                )
+                candidate.requires_python = package.get("requires-python", "")
+                for wheel in package.get("wheels", []):
+                    algo, hash_value = next(iter(wheel["hashes"].items()))
+                    hash_item: FileHash = {"hash": f"{algo}:{hash_value}"}
+                    if "url" in wheel:
+                        hash_item["url"] = wheel["url"]
+                    if "name" in wheel:
+                        hash_item["file"] = wheel["name"]
+                    candidate.hashes.append(hash_item)
+                if sdist := package.get("sdist"):
+                    algo, hash_value = next(iter(sdist["hashes"].items()))
+                    candidate.hashes.append(
+                        {"file": sdist["name"], "url": sdist["url"], "hash": f"{algo}:{hash_value}"}
+                    )
+                self.packages[self._identify_candidate(candidate)] = Package(candidate, [], "")
+
+    def _read_pdm_lock(self, lockfile: Mapping[str, Any]) -> None:
         from pdm.project.lockfile import FLAG_CROSS_PLATFORM, FLAG_STATIC_URLS
 
         root = self.environment.project.root
@@ -180,11 +237,12 @@ class LockedRepository(BaseRepository):
         return candidate.hashes
 
     def evaluate_candidates(self, groups: Collection[str]) -> Iterable[Package]:
+        extras, dependency_groups = self.environment.project.split_extras_groups(list(groups))
         for package in self.packages.values():
             can = package.candidate
-            if can.req.marker and not can.req.marker.matches(self.env_spec):
+            if can.req.marker and not can.req.marker.matches(self.env_spec, extras, dependency_groups):
                 continue
-            if not any(g in can.req.groups for g in groups):
+            if can.req.groups and not any(g in can.req.groups for g in groups):
                 continue
             yield package
 
@@ -220,57 +278,3 @@ class LockedRepository(BaseRepository):
         # clear caches
         if "all_candidates" in self.__dict__:
             del self.__dict__["all_candidates"]
-
-    def format_lockfile(self, groups: Iterable[str] | None, strategy: set[str]) -> TOMLDocument:
-        """Format lock file from a dict of resolved candidates, a mapping of dependencies
-        and a collection of package summaries.
-        """
-        import tomlkit
-
-        from pdm.formats.base import make_array, make_inline_table
-        from pdm.project.lockfile import FLAG_CROSS_PLATFORM, FLAG_INHERIT_METADATA, FLAG_STATIC_URLS
-
-        def _group_sort_key(group: str) -> tuple[bool, str]:
-            return group != "default", group
-
-        project = self.environment.project
-        packages = tomlkit.aot()
-        for entry in sorted(self.packages.values(), key=lambda x: x.candidate.identify()):
-            base = tomlkit.table()
-            base.update(entry.candidate.as_lockfile_entry(project.root))
-            base.add("summary", entry.summary or "")
-            if FLAG_INHERIT_METADATA in strategy:
-                base.add("groups", sorted(entry.candidate.req.groups, key=_group_sort_key))
-                if entry.candidate.req.marker is not None:
-                    base.add("marker", str(entry.candidate.req.marker))
-            if len(entry.dependencies) > 0:
-                base.add("dependencies", make_array(sorted(entry.dependencies), True))
-            if hashes := entry.candidate.hashes:
-                collected = {}
-                for item in hashes:
-                    if FLAG_STATIC_URLS in strategy:
-                        row = {"url": item["url"], "hash": item["hash"]}
-                    else:
-                        row = {"file": item["file"], "hash": item["hash"]}
-                    inline = make_inline_table(row)
-                    # deduplicate and sort
-                    collected[tuple(row.values())] = inline
-                if collected:
-                    base.add("files", make_array([collected[k] for k in sorted(collected)], True))
-            packages.append(base)
-        doc = tomlkit.document()
-        metadata = tomlkit.table()
-        if groups is None:
-            groups = list(project.iter_groups())
-        metadata.update(
-            {
-                "groups": sorted(groups, key=_group_sort_key),
-                "strategy": sorted(strategy),
-                "targets": [t.as_dict() for t in self.targets],
-            }
-        )
-        metadata.pop(FLAG_STATIC_URLS, None)
-        metadata.pop(FLAG_CROSS_PLATFORM, None)
-        doc.add("metadata", metadata)
-        doc.add("package", packages)
-        return doc
diff --git a/src/pdm/project/config.py b/src/pdm/project/config.py
index 365d3440..702b09cf 100644
--- a/src/pdm/project/config.py
+++ b/src/pdm/project/config.py
@@ -25,6 +25,15 @@ DEFAULT_REPOSITORIES = {
 ui = termui.UI()
 
 
+def choices(*choices: str) -> Callable[[str], str]:
+    def converter(value: str) -> str:
+        if value not in choices:
+            raise ValueError(f"Value must be one of {choices}, got {value!r}")
+        return value
+
+    return converter
+
+
 def load_config(file_path: Path) -> dict[str, Any]:
     """Load a nested TOML document into key-value pairs
 
@@ -131,6 +140,12 @@ class Config(MutableMapping[str, str]):
             "PDM_USE_UV",
             ensure_boolean,
         ),
+        "lock.format": ConfigItem(
+            "The format of the lock file, can be `pdm` or `pylock`",
+            "pdm",
+            env_var="PDM_LOCK_FORMAT",
+            coerce=choices("pdm", "pylock"),
+        ),
         "global_project.fallback": ConfigItem(
             "Use the global project implicitly if no local project is found",
             False,
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index d69e800f..9ff4c7b3 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -27,7 +27,7 @@ from pdm.models.repositories import BaseRepository, LockedRepository
 from pdm.models.requirements import Requirement, parse_line, parse_requirement, strip_extras
 from pdm.models.specifiers import PySpecSet
 from pdm.project.config import Config, ensure_boolean
-from pdm.project.lockfile import FLAG_INHERIT_METADATA, Lockfile
+from pdm.project.lockfile import FLAG_INHERIT_METADATA, Lockfile, load_lockfile
 from pdm.project.project_file import PyProject
 from pdm.utils import (
     cd,
@@ -70,7 +70,6 @@ class Project:
     """
 
     PYPROJECT_FILENAME = "pyproject.toml"
-    LOCKFILE_FILENAME = "pdm.lock"
     DEPENDENCIES_RE = re.compile(r"(?:(.+?)-)?dependencies")
 
     def __init__(
@@ -128,12 +127,24 @@ class Project:
     @property
     def lockfile(self) -> Lockfile:
         if self._lockfile is None:
-            self.set_lockfile(self.root / self.LOCKFILE_FILENAME)
+            enable_pylock = self.config["lock.format"] == "pylock"
+            if (path := self.root / "pylock.toml").exists() and enable_pylock:
+                self.set_lockfile(path)
+            elif (path := self.root / "pdm.lock").exists():
+                if enable_pylock:  # pragma: no cover
+                    self.core.ui.warn(
+                        "`lock.format` is set to pylock but pylock.toml is not found, using pdm.lock instead. "
+                        "You can generate pylock with `pdm export -f pylock -o pylock.toml`."
+                    )
+                self.set_lockfile(path)
+            else:
+                file_path = "pylock.toml" if enable_pylock else "pdm.lock"
+                self.set_lockfile(self.root / file_path)
             assert self._lockfile is not None
         return self._lockfile
 
     def set_lockfile(self, path: str | Path) -> None:
-        self._lockfile = Lockfile(path, ui=self.core.ui)
+        self._lockfile = load_lockfile(self, path)
         if self.config.get("use_uv"):
             self._lockfile.default_strategies.discard(FLAG_INHERIT_METADATA)
         if not self.config["strategy.inherit_metadata"]:
@@ -534,6 +545,18 @@ class Project:
 
         return LockedRepository(lockfile, self.sources, self.environment, env_spec=env_spec)
 
+    def split_extras_groups(self, all_groups: list[str]) -> tuple[list[str], list[str]]:
+        """Split the groups into extras and non-extras."""
+        extras: list[str] = []
+        groups: list[str] = []
+        optional_groups = {normalize_name(group) for group in self.pyproject.metadata.get("optional-dependencies", [])}
+        for group in all_groups:
+            if group in optional_groups:
+                extras.append(group)
+            else:
+                groups.append(group)
+        return extras, groups
+
     @property
     def locked_repository(self) -> LockedRepository:
         deprecation_warning("Project.locked_repository is deprecated, use Project.get_locked_repository() instead", 2)
@@ -603,17 +626,18 @@ class Project:
 
         return RichLockReporter(requirements, self.core.ui)
 
-    def get_lock_metadata(self) -> dict[str, Any]:
-        content_hash = "sha256:" + self.pyproject.content_hash("sha256")
-        return {"lock_version": str(self.lockfile.spec_version), "content_hash": content_hash}
-
-    def write_lockfile(self, toml_data: dict, show_message: bool = True, write: bool = True, **_kwds: Any) -> None:
+    def write_lockfile(
+        self, toml_data: Any = None, show_message: bool = True, write: bool = True, **_kwds: Any
+    ) -> None:
         """Write the lock file to disk."""
-        if _kwds:
+        if _kwds:  # pragma: no cover
             deprecation_warning("Extra arguments have been moved to `format_lockfile` function", stacklevel=2)
-        toml_data["metadata"].update(self.get_lock_metadata())
-        self.lockfile.set_data(toml_data)
-
+        if toml_data is not None:  # pragma: no cover
+            deprecation_warning(
+                "Passing toml_data to write_lockfile is deprecated, please use `format_lockfile` instead", stacklevel=2
+            )
+            self.lockfile.set_data(toml_data)
+        self.lockfile.update_hash(self.pyproject.content_hash("sha256"))
         if write and self.enable_write_lockfile:
             self.lockfile.write(show_message)
 
@@ -630,10 +654,9 @@ class Project:
         return can
 
     def is_lockfile_hash_match(self) -> bool:
-        hash_in_lockfile = str(self.lockfile.hash)
-        if not hash_in_lockfile:
+        algo, hash_value = self.lockfile.hash
+        if not hash_value:
             return False
-        algo, hash_value = hash_in_lockfile.split(":")
         content_hash = self.pyproject.content_hash(algo)
         return content_hash == hash_value
 
diff --git a/src/pdm/project/lockfile/__init__.py b/src/pdm/project/lockfile/__init__.py
new file mode 100644
index 00000000..c4f1b4d6
--- /dev/null
+++ b/src/pdm/project/lockfile/__init__.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from pdm.project.lockfile.base import (
+    FLAG_CROSS_PLATFORM,
+    FLAG_DIRECT_MINIMAL_VERSIONS,
+    FLAG_INHERIT_METADATA,
+    FLAG_STATIC_URLS,
+    Lockfile,
+)
+from pdm.project.lockfile.pdmlock import PDMLock
+from pdm.project.lockfile.pylock import PyLock
+
+if sys.version_info >= (3, 11):
+    import tomllib
+else:
+    import tomli as tomllib
+
+
+if TYPE_CHECKING:
+    from pdm.project import Project
+
+__all__ = [
+    "FLAG_CROSS_PLATFORM",
+    "FLAG_DIRECT_MINIMAL_VERSIONS",
+    "FLAG_INHERIT_METADATA",
+    "FLAG_STATIC_URLS",
+    "Lockfile",
+    "PDMLock",
+    "PyLock",
+    "load_lockfile",
+]
+
+
+def load_lockfile(project: Project, path: str | Path) -> Lockfile:
+    """Load a lockfile from the given path."""
+
+    default_lockfile = PyLock if project.config["lock.format"] == "pylock" else PDMLock
+
+    try:
+        with open(path, "rb") as f:
+            data = tomllib.load(f)
+    except OSError:
+        return default_lockfile(path, ui=project.core.ui)
+    else:
+        if data.get("metadata", {}).get("lock_version"):
+            return PDMLock(path, ui=project.core.ui)
+        elif data.get("lock-version"):
+            return PyLock(path, ui=project.core.ui)
+        else:  # pragma: no cover
+            return default_lockfile(path, ui=project.core.ui)
diff --git a/src/pdm/project/lockfile.py b/src/pdm/project/lockfile/base.py
similarity index 55%
rename from src/pdm/project/lockfile.py
rename to src/pdm/project/lockfile/base.py
index 6bb11f61..5dbcac2b 100644
--- a/src/pdm/project/lockfile.py
+++ b/src/pdm/project/lockfile/base.py
@@ -1,15 +1,18 @@
 from __future__ import annotations
 
+import abc
 import enum
 from functools import cached_property
-from typing import Any, Iterable, Mapping
+from typing import TYPE_CHECKING, AbstractSet, Any, Iterable, Mapping
 
 import tomlkit
 
 from pdm import termui
 from pdm.exceptions import PdmUsageError
 from pdm.project.toml_file import TOMLBase
-from pdm.utils import parse_version
+
+if TYPE_CHECKING:
+    from pdm.models.repositories import LockedRepository
 
 GENERATED_COMMENTS = [
     "This file is @generated by PDM.",
@@ -19,9 +22,6 @@ FLAG_STATIC_URLS = "static_urls"
 FLAG_CROSS_PLATFORM = "cross_platform"
 FLAG_DIRECT_MINIMAL_VERSIONS = "direct_minimal_versions"
 FLAG_INHERIT_METADATA = "inherit_metadata"
-SUPPORTED_FLAGS = frozenset(
-    (FLAG_STATIC_URLS, FLAG_CROSS_PLATFORM, FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA)
-)
 
 
 class Compatibility(enum.IntEnum):
@@ -31,53 +31,56 @@ class Compatibility(enum.IntEnum):
     FORWARD = 3  # The current version of PDM is older than the lockfile version.
 
 
-class Lockfile(TOMLBase):
-    spec_version = parse_version("4.5.0")
-
-    @cached_property
-    def default_strategies(self) -> set[str]:
-        return {FLAG_INHERIT_METADATA}
+class Lockfile(TOMLBase, metaclass=abc.ABCMeta):
+    SUPPORTED_FLAGS: AbstractSet[str]
 
     @property
-    def hash(self) -> str:
-        return self._data.get("metadata", {}).get("content_hash", "")
+    @abc.abstractmethod
+    def hash(self) -> tuple[str, str]:
+        """The content hash algo and hash value of the pyproject.toml to generate this lockfile."""
+        pass
 
-    @property
-    def file_version(self) -> str:
-        return self._data.get("metadata", {}).get("lock_version", "")
+    @abc.abstractmethod
+    def update_hash(self, hash_value: str, algo: str = "sha256") -> None:
+        """Update the content hash of the lockfile."""
+        pass
 
     @property
+    @abc.abstractmethod
     def groups(self) -> list[str] | None:
-        return self._data.get("metadata", {}).get("groups")
+        """The groups defined in the lockfile, or None if no groups are defined."""
+        return []
 
     @property
+    @abc.abstractmethod
     def strategy(self) -> set[str]:
-        metadata = self._data.get("metadata", {})
-        if not metadata:
-            return self.default_strategies.copy()
-        result: set[str] = set(metadata.get("strategy", {FLAG_CROSS_PLATFORM}))
-        # Compatibility with old lockfiles
-        if not metadata.get(FLAG_CROSS_PLATFORM, True):
-            result.discard(FLAG_CROSS_PLATFORM)
-        if metadata.get(FLAG_STATIC_URLS, False):
-            result.add(FLAG_STATIC_URLS)
-        return result & SUPPORTED_FLAGS
+        """The strategies used in the lockfile."""
+        return set()
+
+    @cached_property
+    @abc.abstractmethod
+    def default_strategies(self) -> set[str]:
+        """The default strategies to be used if no strategies are defined in the lockfile."""
+        return set()
 
     def apply_strategy_change(self, changes: Iterable[str]) -> set[str]:
+        """Apply the given strategy changes to the current strategy."""
         original = self.strategy
+        supported = self.SUPPORTED_FLAGS
         for change in changes:
             change = change.replace("-", "_").lower()
             if change.startswith("no_"):
-                if change[3:] not in SUPPORTED_FLAGS:
-                    raise PdmUsageError(f"Invalid strategy flag: {change[3:]}, supported: {', '.join(SUPPORTED_FLAGS)}")
+                if change[3:] not in supported:
+                    raise PdmUsageError(f"Invalid strategy flag: {change[3:]}, supported: {', '.join(supported)}")
                 original.discard(change[3:])
             else:
-                if change not in SUPPORTED_FLAGS:
-                    raise PdmUsageError(f"Invalid strategy flag: {change}, supported: {', '.join(SUPPORTED_FLAGS)}")
+                if change not in supported:
+                    raise PdmUsageError(f"Invalid strategy flag: {change}, supported: {', '.join(supported)}")
                 original.add(change)
         return original
 
     def compare_groups(self, groups: Iterable[str]) -> list[str]:
+        """Compare the given groups with the lockfile groups and return the groups that are not in the lockfile."""
         if not self.groups:
             return []
         return list(set(groups).difference(self.groups))
@@ -96,19 +99,14 @@ class Lockfile(TOMLBase):
     def __getitem__(self, key: str) -> dict:
         return self._data[key]
 
+    @abc.abstractmethod
     def compatibility(self) -> Compatibility:
         """We use a three-part versioning scheme for lockfiles:
         The first digit represents backward compatibility and the second digit represents forward compatibility.
         """
-        if not self.exists():
-            return Compatibility.SAME
-        if not self.file_version:
-            return Compatibility.NONE
-        lockfile_version = parse_version(self.file_version)
-        if lockfile_version == self.spec_version:
-            return Compatibility.SAME
-        if lockfile_version.major != self.spec_version.major or lockfile_version.minor > self.spec_version.minor:
-            return Compatibility.NONE
-        if lockfile_version.minor < self.spec_version.minor:
-            return Compatibility.BACKWARD
-        return Compatibility.BACKWARD if lockfile_version.micro < self.spec_version.micro else Compatibility.FORWARD
+
+    @abc.abstractmethod
+    def format_lockfile(self, repository: LockedRepository, groups: Iterable[str] | None, strategy: set[str]) -> None:
+        """Format lock file from a dict of resolved candidates, a mapping of dependencies
+        and a collection of package summaries.
+        """
diff --git a/src/pdm/project/lockfile/pdmlock.py b/src/pdm/project/lockfile/pdmlock.py
new file mode 100644
index 00000000..6e67bb56
--- /dev/null
+++ b/src/pdm/project/lockfile/pdmlock.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from functools import cached_property
+from typing import TYPE_CHECKING, Iterable
+
+import tomlkit
+
+from pdm.project.lockfile.base import (
+    FLAG_CROSS_PLATFORM,
+    FLAG_DIRECT_MINIMAL_VERSIONS,
+    FLAG_INHERIT_METADATA,
+    FLAG_STATIC_URLS,
+    Compatibility,
+    Lockfile,
+)
+from pdm.utils import parse_version
+
+if TYPE_CHECKING:
+    from pdm.models.repositories import LockedRepository
+
+
+class PDMLock(Lockfile):
+    SUPPORTED_FLAGS = frozenset(
+        (FLAG_STATIC_URLS, FLAG_CROSS_PLATFORM, FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA)
+    )
+    spec_version = parse_version("4.5.0")
+
+    @property
+    def hash(self) -> tuple[str, str]:
+        content_hash = self._data.get("metadata", {}).get("content_hash", "")
+        return content_hash.split(":", 1)
+
+    @property
+    def file_version(self) -> str:
+        return self._data.get("metadata", {}).get("lock_version", "")
+
+    @property
+    def groups(self) -> list[str] | None:
+        return self._data.get("metadata", {}).get("groups")
+
+    @cached_property
+    def default_strategies(self) -> set[str]:
+        return {FLAG_INHERIT_METADATA}
+
+    @property
+    def strategy(self) -> set[str]:
+        metadata = self._data.get("metadata", {})
+        if not metadata:
+            return self.default_strategies.copy()
+        result: set[str] = set(metadata.get("strategy", {FLAG_CROSS_PLATFORM}))
+        # Compatibility with old lockfiles
+        if not metadata.get(FLAG_CROSS_PLATFORM, True):
+            result.discard(FLAG_CROSS_PLATFORM)
+        if metadata.get(FLAG_STATIC_URLS, False):
+            result.add(FLAG_STATIC_URLS)
+        return result & self.SUPPORTED_FLAGS
+
+    def update_hash(self, hash_value: str, algo: str = "sha256") -> None:
+        self._data.setdefault("metadata", {})["content_hash"] = f"{algo}:{hash_value}"
+
+    def compatibility(self) -> Compatibility:
+        """We use a three-part versioning scheme for lockfiles:
+        The first digit represents backward compatibility and the second digit represents forward compatibility.
+        """
+        if not self.exists():
+            return Compatibility.SAME
+        if not self.file_version:
+            return Compatibility.NONE
+        lockfile_version = parse_version(self.file_version)
+        if lockfile_version == self.spec_version:
+            return Compatibility.SAME
+        if lockfile_version.major != self.spec_version.major or lockfile_version.minor > self.spec_version.minor:
+            return Compatibility.NONE
+        if lockfile_version.minor < self.spec_version.minor:
+            return Compatibility.BACKWARD
+        return Compatibility.BACKWARD if lockfile_version.micro < self.spec_version.micro else Compatibility.FORWARD
+
+    def format_lockfile(self, repository: LockedRepository, groups: Iterable[str] | None, strategy: set[str]) -> None:
+        """Format lock file from a dict of resolved candidates, a mapping of dependencies
+        and a collection of package summaries.
+        """
+        from pdm.formats.base import make_array, make_inline_table
+
+        def _group_sort_key(group: str) -> tuple[bool, str]:
+            return group != "default", group
+
+        project = repository.environment.project
+        packages = tomlkit.aot()
+        for entry in sorted(repository.packages.values(), key=lambda x: x.candidate.identify()):
+            base = tomlkit.table()
+            base.update(entry.candidate.as_lockfile_entry(project.root))
+            base.add("summary", entry.summary or "")
+            if FLAG_INHERIT_METADATA in strategy:
+                base.add("groups", sorted(entry.candidate.req.groups, key=_group_sort_key))
+                if entry.candidate.req.marker is not None:
+                    base.add("marker", str(entry.candidate.req.marker))
+            if len(entry.dependencies) > 0:
+                base.add("dependencies", make_array(sorted(entry.dependencies), True))
+            if hashes := entry.candidate.hashes:
+                collected = {}
+                for item in hashes:
+                    if FLAG_STATIC_URLS in strategy:
+                        row = {"url": item["url"], "hash": item["hash"]}
+                    else:
+                        row = {"file": item["file"], "hash": item["hash"]}
+                    inline = make_inline_table(row)
+                    # deduplicate and sort
+                    collected[tuple(row.values())] = inline
+                if collected:
+                    base.add("files", make_array([collected[k] for k in sorted(collected)], True))
+            packages.append(base)
+        doc = tomlkit.document()
+        metadata = tomlkit.table()
+        if groups is None:
+            groups = list(project.iter_groups())
+        metadata.update(
+            {
+                "groups": sorted(groups, key=_group_sort_key),
+                "strategy": sorted(strategy),
+                "targets": [t.as_dict() for t in repository.targets],
+                "lock_version": str(self.spec_version),
+            }
+        )
+        metadata.pop(FLAG_STATIC_URLS, None)
+        metadata.pop(FLAG_CROSS_PLATFORM, None)
+        doc.add("metadata", metadata)
+        doc.add("package", packages)
+        self.set_data(doc)
diff --git a/src/pdm/project/lockfile/pylock.py b/src/pdm/project/lockfile/pylock.py
new file mode 100644
index 00000000..8ebc8db8
--- /dev/null
+++ b/src/pdm/project/lockfile/pylock.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from functools import cached_property
+from typing import Iterable
+
+from pdm.exceptions import PdmUsageError
+from pdm.models.repositories.lock import LockedRepository
+from pdm.project.lockfile.base import (
+    FLAG_DIRECT_MINIMAL_VERSIONS,
+    FLAG_INHERIT_METADATA,
+    FLAG_STATIC_URLS,
+    Compatibility,
+    Lockfile,
+)
+
+
+class PyLock(Lockfile):
+    SUPPORTED_FLAGS = frozenset([FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA, FLAG_STATIC_URLS])
+
+    @property
+    def hash(self) -> tuple[str, str]:
+        return next(iter(self._data.get("tool", {}).get("pdm", {}).get("hashes", {}).items()), ("", ""))
+
+    def update_hash(self, hash_value: str, algo: str = "sha256") -> None:
+        self._data.setdefault("tool", {}).setdefault("pdm", {}).setdefault("hashes", {})[algo] = hash_value
+
+    @property
+    def groups(self) -> list[str] | None:
+        return [*self._data.get("dependency-groups", []), *self._data.get("extras", [])]
+
+    @cached_property
+    def default_strategies(self) -> set[str]:
+        return {FLAG_INHERIT_METADATA, FLAG_STATIC_URLS}
+
+    @property
+    def strategy(self) -> set[str]:
+        return set(self._data.get("tool", {}).get("pdm", {}).get("strategy", self.default_strategies))
+
+    def apply_strategy_change(self, changes: Iterable[str]) -> set[str]:
+        for change in changes:
+            change = change.replace("-", "_").lower()
+            if change.startswith("no_") and change[3:] != FLAG_DIRECT_MINIMAL_VERSIONS:
+                raise PdmUsageError(f"Unsupported strategy change for pylock: {change}")
+        return super().apply_strategy_change(changes)
+
+    def format_lockfile(self, repository: LockedRepository, groups: Iterable[str] | None, strategy: set[str]) -> None:
+        from pdm.formats.pylock import PyLockConverter
+
+        converter = PyLockConverter(repository.environment.project, repository)
+        data = converter.convert(groups)
+        data["tool"]["pdm"]["strategy"] = sorted(strategy)
+        self.set_data(data)
+
+    def compatibility(self) -> Compatibility:  # pragma: no cover
+        return Compatibility.SAME


Test patch:
diff --git a/tests/cli/test_lock.py b/tests/cli/test_lock.py
index 9a1f7335..42521f81 100644
--- a/tests/cli/test_lock.py
+++ b/tests/cli/test_lock.py
@@ -8,7 +8,8 @@ from pdm.cli import actions
 from pdm.exceptions import PdmUsageError
 from pdm.models.requirements import parse_requirement
 from pdm.models.specifiers import PySpecSet
-from pdm.project.lockfile import FLAG_CROSS_PLATFORM, Compatibility
+from pdm.project.lockfile import FLAG_CROSS_PLATFORM
+from pdm.project.lockfile.base import Compatibility
 from pdm.utils import parse_version
 from tests import FIXTURES
 
@@ -29,10 +30,13 @@ def test_lock_command(project, pdm, mocker):
 
 
 @pytest.mark.usefixtures("repository")
-def test_lock_dependencies(project):
+@pytest.mark.parametrize("lock_format", ["pdm", "pylock"])
+def test_lock_dependencies(project, lock_format):
     project.add_dependencies(["requests"])
+    project.project_config["lock.format"] = lock_format
     actions.do_lock(project)
-    assert project.lockfile.exists
+    assert project.lockfile.exists()
+    assert project.lockfile._path.name == "pdm.lock" if lock_format == "pdm" else "pylock.toml"
     locked = project.get_locked_repository().candidates
     for package in ("requests", "idna", "chardet", "certifi"):
         assert package in locked
@@ -288,7 +292,7 @@ def test_lock_direct_minimal_versions_real(project, pdm, args):
 )
 def test_lockfile_compatibility(project, monkeypatch, lock_version, expected, pdm):
     pdm(["lock"], obj=project, strict=True)
-    monkeypatch.setattr("pdm.project.lockfile.Lockfile.spec_version", parse_version("4.1.1"))
+    monkeypatch.setattr("pdm.project.lockfile.PDMLock.spec_version", parse_version("4.1.1"))
     project.lockfile._data["metadata"]["lock_version"] = lock_version
     assert project.lockfile.compatibility() == expected
     result = pdm(["lock", "--check"], obj=project)
@@ -427,3 +431,17 @@ def test_lock_with_override_file(project, pdm, constraint):
     assert candidates["requests"].version == "2.20.0b1"
     assert candidates["urllib3"].version == "1.23b0"
     assert "django" not in candidates
+
+
+def test_pylock_add_remove_strategy(project, pdm):
+    project.project_config["lock.format"] = "pylock"
+    pdm(["lock"], obj=project, strict=True)
+    assert project.lockfile.strategy == {"inherit_metadata", "static_urls"}
+    pdm(["lock", "-S", "static_urls"], obj=project, strict=True)
+    pdm(["lock", "-S", "direct_minimal_versions"], obj=project, strict=True)
+    assert project.lockfile.strategy == {"inherit_metadata", "static_urls", "direct_minimal_versions"}
+
+    result = pdm(["lock", "-S", "no_static_urls"], obj=project)
+    assert result.exit_code != 0
+    result = pdm(["lock", "-S", "no_inherit_metadata"], obj=project)
+    assert result.exit_code != 0
diff --git a/tests/fixtures/projects/demo/pylock.toml b/tests/fixtures/projects/demo/pylock.toml
index 6cce390d..b613205c 100644
--- a/tests/fixtures/projects/demo/pylock.toml
+++ b/tests/fixtures/projects/demo/pylock.toml
@@ -104,3 +104,6 @@ marker = "python_version >= \"3.6\" and \"security\" in extras"
 
 [tool.pdm]
 hashes = {sha256 = "2584886ac58a0ae70aa36bc0318b62c3e2c89acc9c21ebb9aee74147c0a9dc06"}
+
+[[tool.pdm.targets]]
+requires_python = ">=3.3"
diff --git a/tests/test_project.py b/tests/test_project.py
index edc5adf4..6ec247ee 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -553,3 +553,30 @@ def test_project_best_match_min(project, mocker):
         return_value=get_python_versions(),
     )
     assert project.get_best_matching_cpython_version(use_minimum=True) == expected
+
+
+def test_default_lockfile_format(project):
+    assert project.lockfile._path.name == "pdm.lock"
+    project.project_config["lock.format"] = "pylock"
+    project._lockfile = None
+    assert project.lockfile._path.name == "pylock.toml"
+
+    with pytest.raises(ValueError):
+        project.project_config["lock.format"] = "invalid"
+
+
+def test_select_lockfile_format(project, pdm, capsys):
+    pdm(["lock"], obj=project, strict=True)
+
+    assert project.lockfile._path.name == "pdm.lock"
+    project.project_config["lock.format"] = "pylock"
+    project._lockfile = None
+    capsys.readouterr()
+    assert project.lockfile._path.name == "pdm.lock"
+    _, err = capsys.readouterr()
+    assert "`lock.format` is set to pylock but pylock.toml is not found" in err
+
+    with cd(project.root):
+        pdm(["export", "-f", "pylock", "-o", "pylock.toml"], strict=True)
+    project._lockfile = None
+    assert project.lockfile._path.name == "pylock.toml"