From 7fdbdceb03f3c0e72b94807e8b1f4df9be5a965b Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 8 Nov 2023 11:41:34 +0800 Subject: [PATCH] feat: improve the compatibility checking for lockfile (#2380) --- news/2164.feature.md | 1 + src/pdm/cli/actions.py | 25 ++++++++++++++----------- src/pdm/project/core.py | 2 +- src/pdm/project/lockfile.py | 37 ++++++++++++++++++++++++------------- tests/cli/test_install.py | 4 ++-- tests/cli/test_lock.py | 23 ++++++++++++++++++++++- 6 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 news/2164.feature.md diff --git a/news/2164.feature.md b/news/2164.feature.md new file mode 100644 index 0000000000..6089a9e826 --- /dev/null +++ b/news/2164.feature.md @@ -0,0 +1 @@ +Improve the lockfile compatibility checking by using 3-digit version numbers. This can distinguish forward-compatibility and backward-compatibility. diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py index 9c39d50357..b837494efd 100644 --- a/src/pdm/cli/actions.py +++ b/src/pdm/cli/actions.py @@ -151,22 +151,25 @@ def resolve_candidates_from_lockfile(project: Project, requirements: Iterable[Re 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 update strategy.""" + """Check if the lock file exists and is up to date. Return the lock strategy.""" + from pdm.project.lockfile import Compatibility + if not project.lockfile.exists(): if raise_not_exist: - raise ProjectError("Lock file does not exist, nothing to install") - project.core.ui.echo("Lock file does not exist", style="warning", err=True) + raise ProjectError("Lockfile does not exist, nothing to install") + project.core.ui.echo("Lockfile does not exist", style="warning", err=True) return "all" - elif not project.lockfile.is_compatible(): - project.core.ui.echo( - "Lock file version is not compatible with PDM, installation may fail", - style="warning", - err=True, - ) - return "reuse" # try to reuse the lockfile if possible + compat = project.lockfile.compatibility() + if compat == Compatibility.NONE: + project.core.ui.echo("Lockfile is not compatible with PDM", style="warning", err=True) + return "reuse" + elif compat == Compatibility.BACKWARD: + project.core.ui.echo("Lockfile is generated on an older version of PDM", style="warning", err=True) + elif compat == Compatibility.FORWARD: + project.core.ui.echo("Lockfile is generated on a newer version of PDM", style="warning", err=True) elif not project.is_lockfile_hash_match(): project.core.ui.echo( - "Lock file hash doesn't match pyproject.toml, packages may be outdated", + "Lockfile hash doesn't match pyproject.toml, packages may be outdated", style="warning", err=True, ) diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py index ab38d3742b..9b789231ea 100644 --- a/src/pdm/project/core.py +++ b/src/pdm/project/core.py @@ -494,7 +494,7 @@ def get_reporter( def get_lock_metadata(self) -> dict[str, Any]: content_hash = "sha256:" + self.pyproject.content_hash("sha256") - return {"lock_version": self.lockfile.spec_version, "content_hash": content_hash} + 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: """Write the lock file to disk.""" diff --git a/src/pdm/project/lockfile.py b/src/pdm/project/lockfile.py index 5efc5be49f..04abbe5ac4 100644 --- a/src/pdm/project/lockfile.py +++ b/src/pdm/project/lockfile.py @@ -1,12 +1,13 @@ from __future__ import annotations +import enum from typing import Any, Iterable, Mapping import tomlkit +from packaging.version import Version from pdm import termui from pdm.exceptions import PdmUsageError -from pdm.models.specifiers import get_specifier from pdm.project.toml_file import TOMLBase GENERATED_COMMENTS = [ @@ -19,8 +20,15 @@ SUPPORTED_FLAGS = frozenset((FLAG_STATIC_URLS, FLAG_CROSS_PLATFORM, FLAG_DIRECT_MINIMAL_VERSIONS)) +class Compatibility(enum.IntEnum): + NONE = 0 # The lockfile can't be read by the current version of PDM. + SAME = 1 # The lockfile version is the same as the current version of PDM. + BACKWARD = 2 # The current version of PDM is newer than the lockfile version. + FORWARD = 3 # The current version of PDM is older than the lockfile version. + + class Lockfile(TOMLBase): - spec_version = "4.4" + spec_version = Version("4.4") @property def hash(self) -> str: @@ -77,16 +85,19 @@ def write(self, show_message: bool = True) -> None: def __getitem__(self, key: str) -> dict: return self._data[key] - def is_compatible(self) -> bool: - """Within the same major version, the higher lockfile generator can work with - lower lockfile but not vice versa. + 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 True - lockfile_version = str(self.file_version) - if not lockfile_version: - return False - if "." not in lockfile_version: - lockfile_version += ".0" - accepted = get_specifier(f"~={lockfile_version}") - return accepted.contains(self.spec_version) + return Compatibility.SAME + if not self.file_version: + return Compatibility.NONE + lockfile_version = 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 diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 26f5cea788..7fc0751ec8 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -151,11 +151,11 @@ def test_install_with_lockfile(project, pdm): result = pdm(["lock", "-v"], obj=project) assert result.exit_code == 0 result = pdm(["install"], obj=project) - assert "Lock file" not in result.stderr + assert "Lockfile" not in result.stderr project.add_dependencies({"pytz": parse_requirement("pytz")}, "default") result = pdm(["install"], obj=project) - assert "Lock file hash doesn't match" in result.stderr + assert "Lockfile hash doesn't match" in result.stderr assert "pytz" in project.locked_repository.all_candidates assert project.is_lockfile_hash_match() diff --git a/tests/cli/test_lock.py b/tests/cli/test_lock.py index 08aa8bb3e4..95e5ec00ef 100644 --- a/tests/cli/test_lock.py +++ b/tests/cli/test_lock.py @@ -2,13 +2,14 @@ from unittest.mock import ANY import pytest +from packaging.version import Version from unearth import Link 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 +from pdm.project.lockfile import FLAG_CROSS_PLATFORM, Compatibility def test_lock_command(project, pdm, mocker): @@ -211,3 +212,23 @@ def test_lock_direct_minimal_versions_real(project, pdm, args): assert locked_candidate.version == "3.6.0" else: assert locked_candidate.version == "3.7.0" + + +@pytest.mark.parametrize( + "lock_version,expected", + [ + ("4.1.0", Compatibility.BACKWARD), + ("4.1.1", Compatibility.SAME), + ("4.1.2", Compatibility.FORWARD), + ("4.2", Compatibility.NONE), + ("3.0", Compatibility.NONE), + ("4.0.1", Compatibility.BACKWARD), + ], +) +def test_lockfile_compatibility(project, monkeypatch, lock_version, expected, pdm): + pdm(["lock"], obj=project, strict=True) + monkeypatch.setattr("pdm.project.lockfile.Lockfile.spec_version", Version("4.1.1")) + project.lockfile._data["metadata"]["lock_version"] = lock_version + assert project.lockfile.compatibility() == expected + result = pdm(["lock", "--check"], obj=project) + assert result.exit_code == (1 if expected == Compatibility.NONE else 0)