From d387b9c163bf16480eb37db57b08850ea61298ae Mon Sep 17 00:00:00 2001 From: luoxiongjian Date: Sun, 11 Oct 2026 01:14:03 +0800 Subject: [PATCH 1/2] Add Version.diff to report the first differing part --- changelog.d/262.feature.rst | 3 +++ docs/usage/compare-versions.rst | 23 +++++++++++++++++ src/semver/version.py | 27 ++++++++++++++++++++ tests/test_diff.py | 45 +++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 changelog.d/262.feature.rst create mode 100644 tests/test_diff.py diff --git a/changelog.d/262.feature.rst b/changelog.d/262.feature.rst new file mode 100644 index 0000000..556eba6 --- /dev/null +++ b/changelog.d/262.feature.rst @@ -0,0 +1,3 @@ +Added ``Version.diff(other)`` to identify the first differing version part, +including build metadata, independently of comparison direction. It returns +``None`` when all parts are identical. diff --git a/docs/usage/compare-versions.rst b/docs/usage/compare-versions.rst index c3f219b..0af3eb3 100644 --- a/docs/usage/compare-versions.rst +++ b/docs/usage/compare-versions.rst @@ -106,3 +106,26 @@ If you need to convert some types into others, refer to :ref:`sec.convert.versio The use of these comparison operators also implies that you can use builtin functions that leverage this capability; builtins including, but not limited to: :func:`max`, :func:`min` (for examples, see :ref:`sec_max_min`) and :func:`sorted`. + +Finding the First Differing Part +------------------------------- + +Use :meth:`~semver.version.Version.diff` with another ``Version`` instance to +find the first differing part, in the order ``major``, ``minor``, ``patch``, +``prerelease``, and ``build``:: + + >>> Version.parse("1.0.0").diff(Version.parse("1.1.5")) + 'minor' + >>> Version.parse("1.1.5").diff(Version.parse("1.0.0")) + 'minor' + >>> Version(1).diff(Version(1)) is None + True + +This reports field differences rather than version precedence. Build metadata +is included even though it is ignored by comparison operators:: + + >>> Version.parse("1.0.0+one").diff(Version.parse("1.0.0+two")) + 'build' + +Convert strings or other representations into ``Version`` instances before +calling ``diff``. Unsupported operands raise ``TypeError``. diff --git a/src/semver/version.py b/src/semver/version.py index 7ead382..f8e5f68 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -457,6 +457,33 @@ def bump_build(self, token: Optional[str] = "build") -> "Version": build += ".0" return cls(self._major, self._minor, self._patch, self._prerelease, build) + def diff(self, other: "Version") -> str | None: + """ + Return the first version part that differs from other. + + Parts are checked in the order major, minor, patch, prerelease, build, + regardless of which version is greater. Unlike precedence comparisons, + build metadata is included. Identical parts return ``None``. + + :param other: the second Version instance + :return: the name of the first differing part, or None + :raises TypeError: if other is not a Version instance + + >>> Version.parse("1.0.0").diff(Version.parse("1.1.5")) + 'minor' + >>> Version.parse("1.0.0+one").diff(Version.parse("1.0.0+two")) + 'build' + >>> Version(1).diff(Version(1)) is None + True + """ + if not isinstance(other, Version): + raise TypeError(f"Expected Version instance, but got {type(other)}") + + for part in self.to_dict(): + if getattr(self, part) != getattr(other, part): + return part + return None + def compare(self, other: Comparable) -> int: """ Compare self with other. diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..ad5e7bb --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,45 @@ +import pytest + +from semver import Version + + +@pytest.mark.parametrize( + "left,right,expected", + [ + ("1.2.3-alpha+one", "2.3.4-beta+two", "major"), + ("1.2.3-alpha+one", "1.3.4-beta+two", "minor"), + ("1.2.3-alpha+one", "1.2.4-beta+two", "patch"), + ("1.2.3-alpha+one", "1.2.3-beta+two", "prerelease"), + ("1.2.3-alpha", "1.2.3", "prerelease"), + ("1.2.3+one", "1.2.3+two", "build"), + ("1.2.3", "1.2.3+one", "build"), + ("1.2.3", "1.2.3", None), + ("1.2.3-alpha+one", "1.2.3-alpha+one", None), + ], +) +def test_diff(left, right, expected): + left, right = Version.parse(left), Version.parse(right) + assert left.diff(right) == expected + assert right.diff(left) == expected + + +@pytest.mark.parametrize( + "other", [None, 1, "1.2.3", b"1.2.3", (1, 2, 3), [1, 2, 3], {"major": 1}] +) +def test_diff_requires_version(other): + with pytest.raises(TypeError, match="Expected Version instance"): + Version(1, 2, 3).diff(other) + + +def test_diff_accepts_subclass(): + class DerivedVersion(Version): + pass + + assert Version(1).diff(DerivedVersion(2)) == "major" + assert DerivedVersion(1).diff(Version(2)) == "major" + + +def test_build_diff_does_not_change_precedence(): + left, right = Version.parse("1.2.3+one"), Version.parse("1.2.3+two") + assert left == right + assert left.diff(right) == "build" From b89678feff94bf23b331df413b97774e74a302a4 Mon Sep 17 00:00:00 2001 From: luoxiongjian Date: Sun, 11 Oct 2026 20:11:19 +0800 Subject: [PATCH 2/2] Align Version.diff with node-semver 7.7.2 classification --- changelog.d/262.feature.rst | 10 +- docs/usage/compare-versions.rst | 72 +++++++++++---- src/semver/version.py | 95 ++++++++++++++----- tests/test_diff.py | 159 ++++++++++++++++++++++++++++---- 4 files changed, 276 insertions(+), 60 deletions(-) diff --git a/changelog.d/262.feature.rst b/changelog.d/262.feature.rst index 556eba6..92cf089 100644 --- a/changelog.d/262.feature.rst +++ b/changelog.d/262.feature.rst @@ -1,3 +1,7 @@ -Added ``Version.diff(other)`` to identify the first differing version part, -including build metadata, independently of comparison direction. It returns -``None`` when all parts are identical. +Added ``Version.diff(other)`` to classify valid version pairs like +node-semver 7.7.2: ``major``, ``minor``, ``patch``, ``premajor``, ``preminor``, +``prepatch``, or ``prerelease``, including its prerelease-to-release rules. +Equal precedence returns ``None`` and build metadata is ignored. It accepts +``Version``, ``str``, ``bytes``, ``dict``, ``tuple``, and ``list`` operands with +Python conversion errors. This classification does not choose a staging-branch +bump policy. diff --git a/docs/usage/compare-versions.rst b/docs/usage/compare-versions.rst index 0af3eb3..96e059e 100644 --- a/docs/usage/compare-versions.rst +++ b/docs/usage/compare-versions.rst @@ -107,25 +107,65 @@ The use of these comparison operators also implies that you can use builtin functions that leverage this capability; builtins including, but not limited to: :func:`max`, :func:`min` (for examples, see :ref:`sec_max_min`) and :func:`sorted`. -Finding the First Differing Part +Classifying Version Differences ------------------------------- -Use :meth:`~semver.version.Version.diff` with another ``Version`` instance to -find the first differing part, in the order ``major``, ``minor``, ``patch``, -``prerelease``, and ``build``:: +Use :meth:`~semver.version.Version.diff` to classify two versions like +`node-semver 7.7.2 `_. +It returns ``major``, ``minor``, ``patch``, ``premajor``, ``preminor``, +``prepatch``, or ``prerelease``. Versions with equal precedence return ``None``, +including versions that differ only in build metadata:: - >>> Version.parse("1.0.0").diff(Version.parse("1.1.5")) + >>> Version.parse("1.0.0").diff("1.1.5") 'minor' - >>> Version.parse("1.1.5").diff(Version.parse("1.0.0")) - 'minor' - >>> Version(1).diff(Version(1)) is None + >>> Version.parse("1.0.0+one").diff("1.0.0+two") is None True -This reports field differences rather than version precedence. Build metadata -is included even though it is ignored by comparison operators:: - - >>> Version.parse("1.0.0+one").diff(Version.parse("1.0.0+two")) - 'build' - -Convert strings or other representations into ``Version`` instances before -calling ``diff``. Unsupported operands raise ``TypeError``. +The higher version determines the classification, regardless of argument order. +If it is a prerelease and a core part differs, the result has a ``pre`` prefix. +Different prereleases with the same core return ``prerelease``:: + + >>> Version.parse("1.2.3").diff("1.2.4-rc.1") + 'prepatch' + >>> Version.parse("1.2.4-rc.1").diff("1.2.3") + 'prepatch' + >>> Version.parse("1.2.3-alpha").diff("1.2.3-beta") + 'prerelease' + +When the lower version is a prerelease and the higher version is a release, +node-semver applies special rules. A lower version with zero minor and patch +always gives ``major``, even if the core versions differ. Otherwise, for equal +core versions, zero patch gives ``minor`` and nonzero patch gives ``patch``:: + + >>> Version.parse("1.0.0-alpha").diff("1.0.0") + 'major' + >>> Version.parse("1.0.0-alpha").diff("1.1.1") + 'major' + >>> Version.parse("1.1.0-alpha").diff("1.1.0") + 'minor' + >>> Version.parse("1.0.1-rc.1").diff("1.0.1") + 'patch' + +``diff`` classifies version differences; it does not decide which bump to apply +on a staging branch. For example, the last pair gives ``patch``, even if your +release policy calls for a minor bump. It does not change ``next_version`` or +the bump methods. + +The operand may be a ``Version`` (including a subclass), ``str``, ``bytes``, +``dict``, ``tuple``, or ``list``. Strings and bytes are parsed using the receiver's +class; collections are passed as keyword or positional constructor arguments:: + + >>> Version(1).diff(b"2.0.0") + 'major' + >>> Version(1).diff({"major": 2}) + 'major' + >>> Version(1).diff((2, 0, 0)) + 'major' + >>> Version(1).diff([2, 0, 0]) + 'major' + +Existing ``Version`` instances are used without reconstruction. Classification +uses SemVer precedence, including on subclass receivers. Unsupported types and +invalid constructor argument types or shapes raise ``TypeError``. Invalid +strings, byte decoding, and invalid constructor values raise ``ValueError``; +conversion errors are propagated as in Python's parsing and construction APIs. diff --git a/src/semver/version.py b/src/semver/version.py index f8e5f68..71d5a6b 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -457,32 +457,85 @@ def bump_build(self, token: Optional[str] = "build") -> "Version": build += ".0" return cls(self._major, self._minor, self._patch, self._prerelease, build) - def diff(self, other: "Version") -> str | None: - """ - Return the first version part that differs from other. - - Parts are checked in the order major, minor, patch, prerelease, build, - regardless of which version is greater. Unlike precedence comparisons, - build metadata is included. Identical parts return ``None``. - - :param other: the second Version instance - :return: the name of the first differing part, or None - :raises TypeError: if other is not a Version instance - - >>> Version.parse("1.0.0").diff(Version.parse("1.1.5")) + # Adapted from node-semver v7.7.2 functions/diff.js: + # https://github.com/npm/node-semver/blob/v7.7.2/functions/diff.js + # The ISC License + # + # Copyright (c) Isaac Z. Schlueter and Contributors + # + # Permission to use, copy, modify, and/or distribute this software for any + # purpose with or without fee is hereby granted, provided that the above + # copyright notice and this permission notice appear in all copies. + # + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + # IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + def diff(self, other: Comparable | bytes) -> str | None: + """ + Classify the difference between versions, matching node-semver 7.7.2. + + Return ``major``, ``minor``, ``patch``, ``premajor``, ``preminor``, + ``prepatch``, or ``prerelease``. Equal precedence returns ``None``; + build metadata is ignored. The higher version determines the + prerelease prefix, so swapping the operands gives the same result. + Prerelease-to-release transitions follow node-semver's special rules. + This classification does not choose a staging-branch bump policy. + + :param other: a Version, str, bytes, dict, tuple, or list. Strings are + parsed and collections are passed to the constructor of type(self). + Existing Version instances, including subclasses, are used directly. + :return: the difference classification, or None for equal precedence + :raises TypeError: if other is unsupported or its constructor arguments + have invalid types or shape + :raises ValueError: if parsing, decoding, or constructor values are invalid + + >>> Version.parse("1.0.0").diff("1.1.5") 'minor' - >>> Version.parse("1.0.0+one").diff(Version.parse("1.0.0+two")) - 'build' - >>> Version(1).diff(Version(1)) is None + >>> Version.parse("1.2.3").diff("1.2.4-rc.1") + 'prepatch' + >>> Version.parse("1.0.0-alpha").diff("1.1.1") + 'major' + >>> Version.parse("1.0.0+one").diff("1.0.0+two") is None True """ - if not isinstance(other, Version): - raise TypeError(f"Expected Version instance, but got {type(other)}") + cls = type(self) + if isinstance(other, String.__args__): # type: ignore + other = cls.parse(other) + elif isinstance(other, dict): + other = cls(**other) + elif isinstance(other, (tuple, list)): + other = cls(*other) + elif not isinstance(other, Version): + raise TypeError( + "Expected str, bytes, dict, tuple, list, or Version instance, " + f"but got {type(other)}" + ) - for part in self.to_dict(): + # Use base SemVer precedence while accepting base and sibling instances + # on subclass receivers, without invoking their custom constructors. + current = Version(self.major, self.minor, self.patch, self.prerelease) + comparison = current.compare(other) + if comparison == 0: + return None + + high, low = (self, other) if comparison > 0 else (other, self) + if low.prerelease and not high.prerelease: + if not low.minor and not low.patch: + return "major" + low_core = (low.major, low.minor, low.patch) + high_core = (high.major, high.minor, high.patch) + if low_core == high_core: + return "minor" if low.minor and not low.patch else "patch" + + prefix = "pre" if high.prerelease else "" + for part in ("major", "minor", "patch"): if getattr(self, part) != getattr(other, part): - return part - return None + return prefix + part + return "prerelease" def compare(self, other: Comparable) -> int: """ diff --git a/tests/test_diff.py b/tests/test_diff.py index ad5e7bb..b15161f 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -1,3 +1,21 @@ +# Adapted from node-semver v7.7.2 test/functions/diff.js: +# https://github.com/npm/node-semver/blob/v7.7.2/test/functions/diff.js +# The ISC License +# +# Copyright (c) Isaac Z. Schlueter and Contributors +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +# IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + import pytest from semver import Version @@ -6,40 +24,141 @@ @pytest.mark.parametrize( "left,right,expected", [ - ("1.2.3-alpha+one", "2.3.4-beta+two", "major"), - ("1.2.3-alpha+one", "1.3.4-beta+two", "minor"), - ("1.2.3-alpha+one", "1.2.4-beta+two", "patch"), - ("1.2.3-alpha+one", "1.2.3-beta+two", "prerelease"), - ("1.2.3-alpha", "1.2.3", "prerelease"), - ("1.2.3+one", "1.2.3+two", "build"), - ("1.2.3", "1.2.3+one", "build"), - ("1.2.3", "1.2.3", None), - ("1.2.3-alpha+one", "1.2.3-alpha+one", None), + ("1.2.3", "0.2.3", "major"), + ("0.2.3", "1.2.3", "major"), + ("1.4.5", "0.2.3", "major"), + ("1.2.3", "2.0.0-pre", "premajor"), + ("2.0.0-pre", "1.2.3", "premajor"), + ("1.2.3", "1.3.3", "minor"), + ("1.0.1", "1.1.0-pre", "preminor"), + ("1.2.3", "1.2.4", "patch"), + ("1.2.3", "1.2.4-pre", "prepatch"), + ("0.0.1", "0.0.1-pre", "patch"), + ("0.0.1", "0.0.1-pre-2", "patch"), + ("1.1.0", "1.1.0-pre", "minor"), + ("1.1.0-pre-1", "1.1.0-pre-2", "prerelease"), + ("1.0.0", "1.0.0", None), + ("1.0.0-1", "1.0.0-1", None), + ("0.0.2-1", "0.0.2", "patch"), + ("0.0.2-1", "0.0.3", "patch"), + ("0.0.2-1", "0.1.0", "minor"), + ("0.0.2-1", "1.0.0", "major"), + ("0.1.0-1", "0.1.0", "minor"), + ("1.0.0-1", "1.0.0", "major"), + ("1.0.0-1", "1.1.1", "major"), + ("1.0.0-1", "2.1.1", "major"), + ("1.0.1-1", "1.0.1", "patch"), + ("0.0.0-1", "0.0.0", "major"), + ("1.0.0-1", "2.0.0", "major"), + ("1.0.0-1", "2.0.0-1", "premajor"), + ("1.0.0-1", "1.1.0-1", "preminor"), + ("1.0.0-1", "1.0.1-1", "prepatch"), + ("1.7.2-1", "1.8.1", "minor"), + ("1.1.1-pre", "2.1.1-pre", "premajor"), + ("1.1.1-pre", "2.1.1", "major"), + ("1.2.3-1", "1.2.3", "patch"), + ("1.4.0-1", "2.3.5", "major"), + ("1.6.1-5", "1.7.2", "minor"), + ("2.0.0-1", "2.1.1", "major"), + ("1.0.0-alpha", "1.1.1", "major"), + ("1.0.1-rc.1", "1.0.1", "patch"), + ("1.2.3-alpha+one", "2.3.4-beta+two", "premajor"), + ("1.2.3-alpha+one", "1.3.4-beta+two", "preminor"), + ("1.2.3-alpha+one", "1.2.4-beta+two", "prepatch"), + ("1.2.3-alpha.2", "1.2.3-alpha.10", "prerelease"), + ("1.2.3-alpha.1", "1.2.3-alpha.1.1", "prerelease"), + ("1.2.3+one", "1.2.3+two", None), + ("1.2.3", "1.2.3+one", None), + ("1.2.3-alpha+one", "1.2.3-alpha+two", None), ], ) def test_diff(left, right, expected): left, right = Version.parse(left), Version.parse(right) + before = left.to_tuple(), right.to_tuple() assert left.diff(right) == expected assert right.diff(left) == expected + assert (left.to_tuple(), right.to_tuple()) == before @pytest.mark.parametrize( - "other", [None, 1, "1.2.3", b"1.2.3", (1, 2, 3), [1, 2, 3], {"major": 1}] + "other", + [Version(2), "2.0.0", b"2.0.0", {"major": "2"}, (2,), [2, 0]], ) -def test_diff_requires_version(other): - with pytest.raises(TypeError, match="Expected Version instance"): - Version(1, 2, 3).diff(other) +def test_diff_accepts_comparable(other): + assert Version(1).diff(other) == "major" + + +@pytest.mark.parametrize( + "other", + [ + "1.2.3-rc.1+two", + b"1.2.3-rc.1+two", + {"major": 1, "minor": 2, "patch": 3, "prerelease": "rc.1", "build": "two"}, + (1, 2, 3, "rc.1", "two"), + [1, 2, 3, "rc.1", "two"], + ], +) +def test_diff_converts_all_parts(other): + assert Version.parse("1.2.3-rc.1+one").diff(other) is None + assert Version(1, 2, 3).diff(other) == "patch" + + +@pytest.mark.parametrize("other", [None, 1, 1.5, object(), {1, 2, 3}]) +def test_diff_rejects_unsupported_types(other): + with pytest.raises( + TypeError, match="Expected str, bytes, dict, tuple, list, or Version" + ): + Version(1).diff(other) + + +@pytest.mark.parametrize("other", ["bad", b"bad", "1.02.3", b"\xff"]) +def test_diff_rejects_invalid_strings(other): + with pytest.raises(ValueError): + Version(1).diff(other) -def test_diff_accepts_subclass(): +@pytest.mark.parametrize( + "other", + [{}, {"unknown": 1}, (1, 2, 3, 4, 5, 6), [], [None]], +) +def test_diff_rejects_invalid_collection_shape(other): + with pytest.raises(TypeError): + Version(1).diff(other) + + +@pytest.mark.parametrize("other", [{"major": -1}, ("bad",), [1, -2]]) +def test_diff_rejects_invalid_collection_value(other): + with pytest.raises(ValueError): + Version(1).diff(other) + + +def test_diff_accepts_subclasses_without_reconstructing_instances(): class DerivedVersion(Version): + def __init__(self, major, *, label): + super().__init__(major) + self.label = label + + def compare(self, other): + raise AssertionError("diff must use SemVer precedence") + + class SiblingVersion(Version): pass - assert Version(1).diff(DerivedVersion(2)) == "major" - assert DerivedVersion(1).diff(Version(2)) == "major" + derived = DerivedVersion(1, label="custom") + for other in (Version(2), DerivedVersion(2, label="other"), SiblingVersion(2)): + assert derived.diff(other) == "major" + assert other.diff(derived) == "major" + assert derived.label == "custom" -def test_build_diff_does_not_change_precedence(): - left, right = Version.parse("1.2.3+one"), Version.parse("1.2.3+two") - assert left == right - assert left.diff(right) == "build" +def test_diff_uses_subclass_conversion_hooks(): + class DerivedVersion(Version): + @classmethod + def parse(cls, version, **kwargs): + return super().parse(version[1:], **kwargs) + + assert DerivedVersion(1).diff("v2.0.0") == "major" + assert DerivedVersion(1).diff(b"v2.0.0") == "major" + assert DerivedVersion(1).diff({"major": 2}) == "major" + assert DerivedVersion(1).diff((2,)) == "major" + assert DerivedVersion(1).diff([2]) == "major"