diff --git a/.gitignore b/.gitignore index c0859cd3..dead3352 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 00000000..738a14fc --- /dev/null +++ b/changelog.d/284.deprecation.rst @@ -0,0 +1,5 @@ +Deprecate the use of :meth:`Version.isvalid`. + +Rename :meth:`Version.isvalid ` +to :meth:`Version.is_valid ` +for consistency reasons with :meth:`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 00000000..6fa8e53a --- /dev/null +++ b/changelog.d/284.doc.rst @@ -0,0 +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 00000000..f13d7300 --- /dev/null +++ b/changelog.d/284.feature.rst @@ -0,0 +1 @@ +Implement :meth:`Version.is_compatible ` to make "is self compatible with X". diff --git a/docs/conf.py b/docs/conf.py index ed888361..9edfda4d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -118,8 +118,8 @@ def find_version(*file_paths): # 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 f869cad3..852ea68b 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 `, +If you need such functions, like :func:`semver.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 `. To keep consistency, the development team +decided to rename the :meth:`isvalid ` to :meth:`is_valid `. diff --git a/docs/migration/replace-deprecated-functions.rst b/docs/migration/replace-deprecated-functions.rst index 9738001c..8762087c 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 00000000..323de3ed --- /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 `. + +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 7aa9615b..a0460df9 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 `: .. 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 ddfc2284..4b8e3fc9 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 3c573d63..b2751429 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 bf949bb4..5edadb0a 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -647,7 +647,7 @@ def replace(self, **parts: Union[int, Optional[str]]) -> "Version": 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 @@ def isvalid(cls, version: str) -> bool: 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/tests/test_semver.py b/tests/test_semver.py index b15bfeaf..782d5c79 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)) diff --git a/tox.ini b/tox.ini index 2b47562e..dd071721 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