Repository: python-semver/python-semver
Pull request #287 (2020-10-16 21:51:07)

Document how to create subclass from VersionInfo
This PR documents the subclass feature of issue #276.

@Anvil Damien, I forgot to add the documentation, so here it is! :wink: Would you be so kind and could have a quick look? It shouldn't be much.

Many thanks!


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 46a94ee..2fdfa8d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,36 @@ Change Log
 All notable changes to this code base will be documented in this file,
 in every released version.
 
+Version 2.12.0
+==============
+
+:Released:
+:Maintainer: Tom Schraitle
+
+Features
+--------
+
+n/a
+
+
+Bug Fixes
+---------
+
+* :gh:`291` (:pr:`292`): Disallow negative numbers of
+  major, minor, and patch for ``semver.VersionInfo``
+
+
+Additions
+---------
+
+n/a
+
+
+Deprecations
+------------
+
+n/a
+
 
 Version 2.11.0
 ==============
diff --git a/docs/semverwithvprefix.py b/docs/semverwithvprefix.py
new file mode 100644
index 0000000..13298d5
--- /dev/null
+++ b/docs/semverwithvprefix.py
@@ -0,0 +1,31 @@
+from semver import VersionInfo
+
+
+class SemVerWithVPrefix(VersionInfo):
+    """
+    A subclass of VersionInfo which allows a "v" prefix
+    """
+
+    @classmethod
+    def parse(cls, version):
+        """
+        Parse version string to a VersionInfo instance.
+
+        :param version: version string with "v" or "V" prefix
+        :type version: str
+        :raises ValueError: when version does not start with "v" or "V"
+        :return: a new instance
+        :rtype: :class:`SemVerWithVPrefix`
+        """
+        if not version[0] in ("v", "V"):
+            raise ValueError(
+                "{v!r}: not a valid semantic version tag. Must start with 'v' or 'V'".format(
+                    v=version
+                )
+            )
+        self = super(SemVerWithVPrefix, cls).parse(version[1:])
+        return self
+
+    def __str__(self):
+        # Reconstruct the tag
+        return "v" + super(SemVerWithVPrefix, self).__str__()
diff --git a/docs/usage.rst b/docs/usage.rst
index 010f1c0..cda5567 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -63,6 +63,14 @@ A :class:`semver.VersionInfo` instance can be created in different ways:
     >>> semver.VersionInfo(**d)
     VersionInfo(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4')
 
+  Keep in mind, the ``major``, ``minor``, ``patch`` parts has to
+  be positive.
+
+    >>> semver.VersionInfo(-1)
+    Traceback (most recent call last):
+    ...
+    ValueError: 'major' is negative. A version can only be positive.
+
   As a minimum requirement, your dictionary needs at least the ``major``
   key, others can be omitted. You get a ``TypeError`` if your
   dictionary contains invalid keys.
@@ -459,6 +467,32 @@ functions that leverage this capability; builtins including, but not limited to:
 (for examples, see :ref:`sec_max_min`) and :func:`sorted`.
 
 
+Determining Version Equality
+----------------------------
+
+Version equality means for semver, that major, minor, patch, and prerelease
+parts are equal in both versions you compare. The build part is ignored.
+For example::
+
+    >>> v = semver.VersionInfo.parse("1.2.3-rc4+1e4664d")
+    >>> v == "1.2.3-rc4+dedbeef"
+    True
+
+This also applies when a :class:`semver.VersionInfo` is a member of a set, or a
+dictionary key::
+
+    >>> d = {}
+    >>> v1 = semver.VersionInfo.parse("1.2.3-rc4+1e4664d")
+    >>> v2 = semver.VersionInfo.parse("1.2.3-rc4+dedbeef")
+    >>> d[v1] = 1
+    >>> d[v2]
+    1
+    >>> s = set()
+    >>> s.add(v1)
+    >>> v2 in s
+    True
+
+
 
 Comparing Versions through an Expression
 ----------------------------------------
@@ -537,6 +571,8 @@ The "old way" with :func:`semver.max_ver` or :func:`semver.min_ver` is still ava
     '1.0.0'
 
 
+.. _sec_dealing_with_invalid_versions:
+
 Dealing with Invalid Versions
 -----------------------------
 
@@ -715,3 +751,39 @@ the following methods:
    For further details, see the section
    `Overriding the default filter <https://docs.python.org/3/library/warnings.html#overriding-the-default-filter>`_
    of the Python documentation.
+
+
+.. _sec_creating_subclasses_from_versioninfo:
+
+Creating Subclasses from VersionInfo
+------------------------------------
+
+If you do not like creating functions to modify the behavior of semver
+(as shown in section :ref:`sec_dealing_with_invalid_versions`), you can
+also create a subclass of the :class:`VersionInfo` class.
+
+For example, if you want to output a "v" prefix before a version,
+but the other behavior is the same, use the following code:
+
+.. literalinclude:: semverwithvprefix.py
+   :language: python
+   :lines: 4-
+
+
+The derived class :class:`SemVerWithVPrefix` can be used like
+the original class:
+
+.. code-block:: python
+
+     >>> v1 = SemVerWithVPrefix.parse("v1.2.3")
+     >>> assert str(v1) == "v1.2.3"
+     >>> print(v1)
+     v1.2.3
+     >>> v2 = SemVerWithVPrefix.parse("v2.3.4")
+     >>> v2 > v1
+     True
+     >>> bad = SemVerWithVPrefix.parse("1.2.4")
+     Traceback (most recent call last):
+     ...
+     ValueError: '1.2.4': not a valid semantic version tag. Must start with 'v' or 'V'
+
diff --git a/semver.py b/semver.py
index 1e15a08..7234714 100644
--- a/semver.py
+++ b/semver.py
@@ -14,7 +14,7 @@ PY2 = sys.version_info[0] == 2
 PY3 = sys.version_info[0] == 3
 
 
-__version__ = "2.11.0"
+__version__ = "2.12.0"
 __author__ = "Kostiantyn Rybnikov"
 __author_email__ = "k-bx@k-bx.com"
 __maintainer__ = ["Sebastien Celles", "Tom Schraitle"]
@@ -243,9 +243,24 @@ class VersionInfo(object):
     )
 
     def __init__(self, major, minor=0, patch=0, prerelease=None, build=None):
-        self._major = int(major)
-        self._minor = int(minor)
-        self._patch = int(patch)
+        # Build a dictionary of the arguments except prerelease and build
+        version_parts = {
+            "major": major,
+            "minor": minor,
+            "patch": patch,
+        }
+
+        for name, value in version_parts.items():
+            value = int(value)
+            version_parts[name] = value
+            if value < 0:
+                raise ValueError(
+                    "{!r} is negative. A version can only be positive.".format(name)
+                )
+
+        self._major = version_parts["major"]
+        self._minor = version_parts["minor"]
+        self._patch = version_parts["patch"]
         self._prerelease = None if prerelease is None else str(prerelease)
         self._build = None if build is None else str(build)
 
@@ -626,7 +641,7 @@ build='build.10')
         return version
 
     def __hash__(self):
-        return hash(self.to_tuple())
+        return hash(self.to_tuple()[:4])
 
     def finalize_version(self):
         """
diff --git a/setup.cfg b/setup.cfg
index a22f03a..5b2a59b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -22,3 +22,5 @@ exclude =
     __pycache__,
     build,
     dist
+    docs
+    conftest.py


Test patch:
diff --git a/conftest.py b/conftest.py
index 3e05cb5..e6a1f04 100644
--- a/conftest.py
+++ b/conftest.py
@@ -5,9 +5,11 @@ import sys
 sys.path.insert(0, "docs")
 
 from coerce import coerce  # noqa:E402
+from semverwithvprefix import SemVerWithVPrefix
 
 
 @pytest.fixture(autouse=True)
 def add_semver(doctest_namespace):
     doctest_namespace["semver"] = semver
     doctest_namespace["coerce"] = coerce
+    doctest_namespace["SemVerWithVPrefix"] = SemVerWithVPrefix
diff --git a/test_semver.py b/test_semver.py
index 0aeedbd..bb8cbba 100644
--- a/test_semver.py
+++ b/test_semver.py
@@ -73,6 +73,20 @@ def test_fordocstrings(func):
     assert func.__doc__, "Need a docstring for function %r" % func.__name
 
 
+@pytest.mark.parametrize(
+    "ver",
+    [
+        {"major": -1},
+        {"major": 1, "minor": -2},
+        {"major": 1, "minor": 2, "patch": -3},
+        {"major": 1, "minor": -2, "patch": 3},
+    ],
+)
+def test_should_not_allow_negative_numbers(ver):
+    with pytest.raises(ValueError, match=".* is negative. .*"):
+        VersionInfo(**ver)
+
+
 @pytest.mark.parametrize(
     "version,expected",
     [
@@ -679,6 +693,20 @@ def test_parse_version_info_str_hash():
     d[v] = ""  # to ensure that VersionInfo are hashable
 
 
+def test_equal_versions_have_equal_hashes():
+    v1 = parse_version_info("1.2.3-alpha.1.2+build.11.e0f985a")
+    v2 = parse_version_info("1.2.3-alpha.1.2+build.22.a589f0e")
+    assert v1 == v2
+    assert hash(v1) == hash(v2)
+    d = {}
+    d[v1] = 1
+    d[v2] = 2
+    assert d[v1] == 2
+    s = set()
+    s.add(v1)
+    assert v2 in s
+
+
 def test_parse_method_for_version_info():
     s_version = "1.2.3-alpha.1.2+build.11.e0f985a"
     v = VersionInfo.parse(s_version)
@@ -1134,9 +1162,9 @@ def test_subclass_from_versioninfo():
     class SemVerWithVPrefix(VersionInfo):
         @classmethod
         def parse(cls, version):
-            if not version.startswith("v"):
+            if not version[0] in ("v", "V"):
                 raise ValueError(
-                    "{v}: not a valid semantic version tag".format(v=version)
+                    "{v!r}: version must start with 'v' or 'V'".format(v=version)
                 )
             return super(SemVerWithVPrefix, cls).parse(version[1:])