Repository: python-semver/python-semver (python)

Issue:
Feature request: concise "compatibility" matching
I'd really like a concise expression for "is version x compatible with version y?"

Specifically, suppose I write code against one version of an API, and the API exposes a semver-compliant version. Is `v_caller` compatible with `v_api`?

The most concise expression I can come up with is something like `v_caller <= v_api and v_caller.major == v_api.major`, but I really wish this was simpler and more readable. Maybe `v_caller.compatible(v_api)`; or maybe `v_caller.can_call(v_api)` better illustrates which operand is which.

I know this would be covered by #241, but its scope is quite broad. I wanted to specifically call out this feature.

Pull request description:
Fix #284: implement "is compatible with" method
This is a first implementation of `Version.is_compatible`.

The algorithm checks:

* if the two majors are different, it's incompatible
* if the two majors are equal, but the minor of the callee is lower than the caller, it's incompatible
* if both prereleases are present and different, it's incompatible
* otherwise it's compatible

@rafalkrupinski /@sbrudenell / @Lexicality maybe you want to try/review it? :slightly_smiling_face: 

# TODOs
* [x] Improve changelog
* [x] Improve docstring with example(s)
* [x] Add missing documentation
* [x] Review code
* [x] Clarify naming

Patch:
diff --git a/.gitignore b/.gitignore
index c0859cd..dead335 100644
--- a/.gitignore
+++ b/.gitignore
@@ -268,3 +268,6 @@ fabric.properties
 
 docs/_api
 !docs/_api/semver.__about__.rst
+
+# For node
+node_modules/
diff --git a/changelog.d/284.deprecation.rst b/changelog.d/284.deprecation.rst
new file mode 100644
index 0000000..738a14f
--- /dev/null
+++ b/changelog.d/284.deprecation.rst
@@ -0,0 +1,5 @@
+Deprecate the use of :meth:`Version.isvalid`.
+
+Rename :meth:`Version.isvalid <semver.version.Version.isvalid>`
+to :meth:`Version.is_valid <semver.version.Version.is_valid>`
+for consistency reasons with :meth:`Version.is_compatible <semver.version.Version.is_compatible>`
\ No newline at end of file
diff --git a/changelog.d/284.doc.rst b/changelog.d/284.doc.rst
new file mode 100644
index 0000000..6fa8e53
--- /dev/null
+++ b/changelog.d/284.doc.rst
@@ -0,0 +1,1 @@
+Document deprecation of :meth:`Version.isvalid`.
\ No newline at end of file
diff --git a/changelog.d/284.feature.rst b/changelog.d/284.feature.rst
new file mode 100644
index 0000000..f13d730
--- /dev/null
+++ b/changelog.d/284.feature.rst
@@ -0,0 +1,1 @@
+Implement :meth:`Version.is_compatible <semver.version.Version.is_compatible>` to make "is self compatible with X".
diff --git a/docs/conf.py b/docs/conf.py
index ed88836..9edfda4 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -118,8 +118,8 @@ todo_include_todos = False
 # Markup to shorten external links
 # See https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html
 extlinks = {
-    "gh": ("https://github.com/python-semver/python-semver/issues/%s", "#"),
-    "pr": ("https://github.com/python-semver/python-semver/pull/%s", "PR #"),
+    "gh": ("https://github.com/python-semver/python-semver/issues/%s", "#%s"),
+    "pr": ("https://github.com/python-semver/python-semver/pull/%s", "PR #%s"),
 }
 
 # -- Options for HTML output ----------------------------------------------
diff --git a/docs/migration/migratetosemver3.rst b/docs/migration/migratetosemver3.rst
index f869cad..852ea68 100644
--- a/docs/migration/migratetosemver3.rst
+++ b/docs/migration/migratetosemver3.rst
@@ -3,8 +3,9 @@
 Migrating from semver2 to semver3
 =================================
 
-This document describes the visible differences for
+This section describes the visible differences for
 users and how your code stays compatible for semver3.
+Some changes are backward incompatible.
 
 Although the development team tries to make the transition
 to semver3 as smooth as possible, at some point change
@@ -34,9 +35,16 @@ Use semver.cli instead of semver
 --------------------------------
 
 All functions related to CLI parsing are moved to :mod:`semver.cli`.
-If you are such functions, like :func:`semver.cmd_bump <semver.cli.cmd_bump>`,
+If you need such functions, like :func:`semver.cmd_bump <semver.cli.cmd_bump>`,
 import it from :mod:`semver.cli` in the future:
 
 .. code-block:: python
 
    from semver.cli import cmd_bump
+
+
+Use semver.Version.is_valid instead of semver.Version.isvalid
+-------------------------------------------------------------
+
+The pull request :pr:`284` introduced the method :meth:`Version.is_compatible <semver.Version.is_compatible>`. To keep consistency, the development team
+decided to rename the :meth:`isvalid <semver.Version.isvalid>` to :meth:`is_valid <semver.Version.is_valid>`.
diff --git a/docs/migration/replace-deprecated-functions.rst b/docs/migration/replace-deprecated-functions.rst
index 9738001..8762087 100644
--- a/docs/migration/replace-deprecated-functions.rst
+++ b/docs/migration/replace-deprecated-functions.rst
@@ -31,6 +31,11 @@ them with code which is compatible for future versions:
 
   Likewise with the other module level functions.
 
+* :func:`semver.Version.isvalid`
+
+   Replace it with :meth:`semver.Version.is_valid`:
+
+
 * :func:`semver.finalize_version`
 
   Replace it with :func:`semver.Version.finalize_version`:
diff --git a/docs/usage/check-compatible-semver-version.rst b/docs/usage/check-compatible-semver-version.rst
new file mode 100644
index 0000000..323de3e
--- /dev/null
+++ b/docs/usage/check-compatible-semver-version.rst
@@ -0,0 +1,95 @@
+Checking for a Compatible Semver Version
+========================================
+
+To check if a *change* from a semver version ``a`` to a semver
+version ``b`` is *compatible* according to semver rule, use the method
+:meth:`Version.is_compatible <semver.version.Version.is_compatible>`.
+
+The expression ``a.is_compatible(b) is True`` if one of the following
+statements is true:
+
+* both versions are equal, or
+* both majors are equal and higher than 0. The same applies for both
+  minor parts. Both pre-releases are equal, or
+* both majors are equal and higher than 0. The minor of ``b``'s
+  minor version is higher then ``a``'s. Both pre-releases are equal.
+
+In all other cases, the result is false.
+
+Keep in mind, the method *does not* check patches!
+
+
+* Two different majors:
+
+  .. code-block:: python
+
+      >>> a = Version(1, 1, 1)
+      >>> b = Version(2, 0, 0)
+      >>> a.is_compatible(b)
+      False
+      >>> b.is_compatible(a)
+      False
+
+* Two different minors:
+
+  .. code-block:: python
+
+      >>> a = Version(1, 1, 0) 
+      >>> b = Version(1, 0, 0)
+      >>> a.is_compatible(b)
+      False
+      >>> b.is_compatible(a)
+      True
+
+* The same two majors and minors:
+
+  .. code-block:: python
+
+      >>> a = Version(1, 1, 1) 
+      >>> b = Version(1, 1, 0) 
+      >>> a.is_compatible(b)
+      True
+      >>> b.is_compatible(a)
+      True
+
+* Release and pre-release:
+
+  .. code-block:: python
+
+      >>> a = Version(1, 1, 1)
+      >>> b = Version(1, 0, 0,'rc1')
+      >>> a.is_compatible(b)
+      False
+      >>> b.is_compatible(a)
+      False
+
+* Different pre-releases:
+
+  .. code-block:: python
+
+      >>> a = Version(1, 0, 0, 'rc1')
+      >>> b = Version(1, 0, 0, 'rc2')
+      >>> a.is_compatible(b)
+      False
+      >>> b.is_compatible(a)
+      False
+
+* Identical pre-releases:
+
+  .. code-block:: python
+
+      >>> a = Version(1, 0, 0,'rc1')
+      >>> b = Version(1, 0, 0,'rc1')
+      >>> a.is_compatible(b)
+      True
+
+* All major zero versions are incompatible with anything but itself:
+
+  .. code-block:: python
+
+      >>> Version(0, 1, 0).is_compatible(Version(0, 1, 1))
+      False
+
+      # Only identical versions are compatible for major zero versions:
+      >>> Version(0, 1, 0).is_compatible(Version(0, 1, 0))
+      True
diff --git a/docs/usage/check-valid-semver-version.rst b/docs/usage/check-valid-semver-version.rst
index 7aa9615..a0460df 100644
--- a/docs/usage/check-valid-semver-version.rst
+++ b/docs/usage/check-valid-semver-version.rst
@@ -6,7 +6,7 @@ classmethod :func:`Version.isvalid <semver.version.Version.isvalid>`:
 
 .. code-block:: python
 
-    >>> Version.isvalid("1.0.0")
+    >>> Version.is_valid("1.0.0")
     True
-    >>> Version.isvalid("invalid")
+    >>> Version.is_valid("invalid")
     False
diff --git a/docs/usage/index.rst b/docs/usage/index.rst
index ddfc228..4b8e3fc 100644
--- a/docs/usage/index.rst
+++ b/docs/usage/index.rst
@@ -8,6 +8,7 @@ Using semver
    create-a-version
    parse-version-string
    check-valid-semver-version
+   check-compatible-semver-version
    access-parts-of-a-version
    access-parts-through-index
    replace-parts-of-a-version
diff --git a/src/semver/cli.py b/src/semver/cli.py
index 3c573d6..b275142 100644
--- a/src/semver/cli.py
+++ b/src/semver/cli.py
@@ -54,7 +54,7 @@ def cmd_check(args: argparse.Namespace) -> None:
 
     :param args: The parsed arguments
     """
-    if Version.isvalid(args.version):
+    if Version.is_valid(args.version):
         return None
     raise ValueError("Invalid version %r" % args.version)
 
diff --git a/src/semver/version.py b/src/semver/version.py
index bf949bb..5edadb0 100644
--- a/src/semver/version.py
+++ b/src/semver/version.py
@@ -647,7 +647,7 @@ prerelease='pre.2', build='build.4')
             raise TypeError(error)
 
     @classmethod
-    def isvalid(cls, version: str) -> bool:
+    def is_valid(cls, version: str) -> bool:
         """
         Check if the string is a valid semver version.
 
@@ -663,6 +663,42 @@ prerelease='pre.2', build='build.4')
         except ValueError:
             return False
 
+    def is_compatible(self, other: "Version") -> bool:
+        """
+        Check if current version is compatible with other version.
+
+        The result is True, if either of the following is true:
+
+        * both versions are equal, or
+        * both majors are equal and higher than 0. Same for both minors.
+          Both pre-releases are equal, or
+        * both majors are equal and higher than 0. The minor of b's
+          minor version is higher then a's. Both pre-releases are equal.
+
+        The algorithm does *not* check patches.
+
+        :param other: the version to check for compatibility
+        :return: True, if ``other`` is compatible with the old version,
+                 otherwise False
+
+        >>> Version(1, 1, 0).is_compatible(Version(1, 0, 0))
+        False
+        >>> Version(1, 0, 0).is_compatible(Version(1, 1, 0))
+        True
+        """
+        if not isinstance(other, Version):
+            raise TypeError(f"Expected a Version type but got {type(other)}")
+
+        # All major-0 versions should be incompatible with anything but itself
+        if (0 == self.major == other.major) and (self[:4] != other[:4]):
+            return False
+
+        return (
+            (self.major == other.major)
+            and (other.minor >= self.minor)
+            and (self.prerelease == other.prerelease)
+        )
+
 
 #: Keep the VersionInfo name for compatibility
 VersionInfo = Version
diff --git a/tox.ini b/tox.ini
index 2b47562..dd07172 100644
--- a/tox.ini
+++ b/tox.ini
@@ -18,8 +18,8 @@ python =
 
 [testenv]
 description = Run test suite for {basepython}
-allowlist_externals = make
 skip_install = true
+allowlist_externals = make
 commands = pytest {posargs:}
 deps =
     pytest


Test patch:
diff --git a/tests/test_semver.py b/tests/test_semver.py
index b15bfea..782d5c7 100644
--- a/tests/test_semver.py
+++ b/tests/test_semver.py
@@ -73,10 +73,65 @@ def test_should_be_able_to_use_integers_as_prerelease_build():
 
 
 def test_should_versioninfo_isvalid():
-    assert Version.isvalid("1.0.0") is True
-    assert Version.isvalid("foo") is False
+    assert Version.is_valid("1.0.0") is True
+    assert Version.is_valid("foo") is False
 
 
 def test_versioninfo_compare_should_raise_when_passed_invalid_value():
     with pytest.raises(TypeError):
         Version(1, 2, 3).compare(4)
+
+
+@pytest.mark.parametrize(
+    "old, new",
+    [
+        ((1, 2, 3), (1, 2, 3)),
+        ((1, 2, 3), (1, 2, 4)),
+        ((1, 2, 4), (1, 2, 3)),
+        ((1, 2, 3, "rc.0"), (1, 2, 4, "rc.0")),
+        ((0, 1, 0), (0, 1, 0)),
+    ],
+)
+def test_should_succeed_compatible_match(old, new):
+    old = Version(*old)
+    new = Version(*new)
+    assert old.is_compatible(new)
+
+
+@pytest.mark.parametrize(
+    "old, new",
+    [
+        ((1, 1, 0), (1, 0, 0)),
+        ((2, 0, 0), (1, 5, 0)),
+        ((1, 2, 3, "rc.1"), (1, 2, 3, "rc.0")),
+        ((1, 2, 3, "rc.1"), (1, 2, 4, "rc.0")),
+        ((0, 1, 0), (0, 1, 1)),
+        ((1, 0, 0), (1, 0, 0, "rc1")),
+        ((1, 0, 0, "rc1"), (1, 0, 0)),
+    ],
+)
+def test_should_fail_compatible_match(old, new):
+    old = Version(*old)
+    new = Version(*new)
+    assert not old.is_compatible(new)
+
+
+@pytest.mark.parametrize(
+    "wrongtype",
+    [
+        "wrongtype",
+        dict(a=2),
+        list(),
+    ],
+)
+def test_should_fail_with_incompatible_type_for_compatible_match(wrongtype):
+    with pytest.raises(TypeError, match="Expected a Version type .*"):
+        v = Version(1, 2, 3)
+        v.is_compatible(wrongtype)
+
+
+def test_should_succeed_with_compatible_subclass_for_is_compatible():
+    class CustomVersion(Version):
+        ...
+
+    assert CustomVersion(1, 0, 0).is_compatible(Version(1, 0, 0))