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):