Skip to content

Commit

Permalink
feat: improve the compatibility checking for lockfile (#2380)
Browse files Browse the repository at this point in the history
  • Loading branch information
frostming committed Dec 1, 2023
1 parent f8ac289 commit 7fdbdce
Show file tree
Hide file tree
Showing 6 changed files with 64 additions and 28 deletions.
1 change: 1 addition & 0 deletions news/2164.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve the lockfile compatibility checking by using 3-digit version numbers. This can distinguish forward-compatibility and backward-compatibility.
25 changes: 14 additions & 11 deletions src/pdm/cli/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion src/pdm/project/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
37 changes: 24 additions & 13 deletions src/pdm/project/lockfile.py
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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:
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/cli/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
23 changes: 22 additions & 1 deletion tests/cli/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

0 comments on commit 7fdbdce

Please sign in to comment.