diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ba5f0fc..5df53b5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,10 @@ Unreleased
* Support Python 3.15.
+* Compare versions using PEP 440 equivalence rather than exact string equality.
+ Previously a requirement pinned as ``gdal==3.10`` was reported as a mismatch against the installed ``3.10.0``, even though the two versions are equal under PEP 440.
+ This adds a dependency on `packaging `__.
+
* Switch package build backend from setuptools to `uv_build `__.
This makes builds with uv about nine times faster, since uv runs the backend natively, without creating a build environment or spawning a Python process.
Additionally, source distributions no longer include test files, which setuptools previously included incompletely, missing the files needed to actually run them.
diff --git a/pyproject.toml b/pyproject.toml
index 40dfd0c..479294f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,9 @@ classifiers = [
"Topic :: Utilities",
"Typing :: Typed",
]
-dependencies = []
+dependencies = [
+ "packaging>=22",
+]
urls = { Changelog = "https://github.com/adamchainz/pip-lock/blob/main/CHANGELOG.rst", Funding = "https://adamj.eu/books/", Repository = "https://github.com/adamchainz/pip-lock" }
[dependency-groups]
diff --git a/src/pip_lock/__init__.py b/src/pip_lock/__init__.py
index 9c5ef0a..9e7cc09 100644
--- a/src/pip_lock/__init__.py
+++ b/src/pip_lock/__init__.py
@@ -6,6 +6,8 @@
from collections.abc import Iterable
from importlib.metadata import distributions as get_distributions
+from packaging.version import InvalidVersion, Version
+
def read_pip(filename: str) -> list[str]:
"""Return lines in pip file, concatenating included requirement files."""
@@ -55,6 +57,18 @@ def normalize_name(name: str) -> str:
return name.lower().replace("_", "-").replace(".", "-")
+def versions_equal(expected: str, installed: str) -> bool:
+ """Compare two version strings under PEP 440 equivalence."""
+ if expected == installed:
+ return True
+ try:
+ return bool(Version(expected) == Version(installed))
+ except InvalidVersion:
+ # Not PEP 440 versions, so exact string equality above was the only
+ # comparison available.
+ return False
+
+
def get_mismatches(requirements_file_path: str) -> dict[str, tuple[str, str | None]]:
"""Return a dictionary of requirement mismatches."""
pip_lines = read_pip(requirements_file_path)
@@ -66,7 +80,7 @@ def get_mismatches(requirements_file_path: str) -> dict[str, tuple[str, str | No
installed_version = installed.get(name)
if installed_version is None:
mismatches[name] = (expected_version, None)
- elif installed_version != expected_version:
+ elif not versions_equal(expected_version, installed_version):
mismatches[name] = (expected_version, installed_version)
return mismatches
diff --git a/tests/test_pip_lock.py b/tests/test_pip_lock.py
index f6f15ad..e3d82da 100644
--- a/tests/test_pip_lock.py
+++ b/tests/test_pip_lock.py
@@ -206,6 +206,60 @@ def test_package_with_extra(self, tmp_path):
assert result == {}
+ def test_no_mismatches_trailing_zero(self, tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text("package==3.10\n")
+
+ with mock_get_distributions({"package": "3.10.0"}):
+ result = get_mismatches(str(requirements))
+
+ assert result == {}
+
+ def test_no_mismatches_prerelease_spelling(self, tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text("package==1.0alpha1\n")
+
+ with mock_get_distributions({"package": "1.0a1"}):
+ result = get_mismatches(str(requirements))
+
+ assert result == {}
+
+ def test_mismatch_differing_release(self, tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text("package==3.10\n")
+
+ with mock_get_distributions({"package": "3.10.1"}):
+ result = get_mismatches(str(requirements))
+
+ assert result == {"package": ("3.10", "3.10.1")}
+
+ def test_mismatch_differing_epoch(self, tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text("package==1!1.0\n")
+
+ with mock_get_distributions({"package": "1.0"}):
+ result = get_mismatches(str(requirements))
+
+ assert result == {"package": ("1!1.0", "1.0")}
+
+ def test_mismatch_non_pep440_versions(self, tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text("package==not-a-version\n")
+
+ with mock_get_distributions({"package": "also-not-a-version"}):
+ result = get_mismatches(str(requirements))
+
+ assert result == {"package": ("not-a-version", "also-not-a-version")}
+
+ def test_no_mismatches_identical_non_pep440_versions(self, tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text("package==not-a-version\n")
+
+ with mock_get_distributions({"package": "not-a-version"}):
+ result = get_mismatches(str(requirements))
+
+ assert result == {}
+
class TestPrintErrors:
def test_errors(self, capsys):