Repository: python-semver/python-semver
Pull request #222 (2020-02-23 14:34:07)

First implementation of next_version
This PR contains (based on #221):

* Implement semver.next_version(version, part, *, prerelease_token="rc")
* Add test cases

TODOs:

* [x] Implement `semver.VersionInfo.next_version` counterpart
* [ ] Describe the function(s) in our documentation

----
@gsakkis This is a first draft, feel free to comment. :wink: 

gsakkis: @tomschr here are a few more (currently failing) test cases:
```
        ("0.1.5-rc.1", "prerelease", "0.1.5-rc.2"),
        ("0.1.5-rc.2", "patch", "0.1.5"),
        ("0.2.0-rc.1", "minor", "0.2.0"),
        ("1.0.0-rc.1", "major", "1.0.0"),
```
gsakkis: 
Open issues:
1. How to deal with the following cases:
  - ("0.2.0-rc.1", "patch", ???)
  - ("1.0.0-rc.1", "patch", ???)
  - ("1.0.0-rc.1", "minor", ???)
  My suggestion would be to raise a custom exception, e.g. `IndeterminableVersionException`.

2. I came across another situation I hadn't thought of before: say you're in `v0.1.4` and want to bump to next prerelease version. With the current patch this is `v0.1.5-rc.1`. This makes sense if you plan for the next non-prerelease version to be a patch upgrade, i.e. `v0.1.5`. But what if you plan a minor or major upgrade right after `v0.1.4`? In that case the next prerelease should be `v0.2.0-rc1` (for minor) or `v1.0.0-rc1` (for major). 

Currently there is not enough information to determine which of the three alternatives to choose. It needs either an extra parameter, or since this affects only prereleases, allow two more `part` values: `prerelease-minor` and `prerelease-major` (and perhaps `prerelease-patch` for consistency, which would be equivalent to `prerelease`). So in short:
```
("0.1.4", "prerelease", "0.1.5-rc.1")
("0.1.4", "prerelease-patch", "0.1.5-rc.1") # optional
("0.1.4", "prerelease-minor", "0.2.0-rc.1")
("0.1.4", "prerelease-major", "1.0.0-rc.1")
```
What do you think?


tomschr: > @tomschr here are a few more (currently failing) test cases:
>  [...]

I've added them to the test file and fixed them. Additionally, I've added you as co-author.

The implementation currently looks horrible as it contains a lot of `elif` parts. We can improve that later.


gsakkis: Getting closer :) Four more failing cases:
```
        ("0.2.5-rc.1", "minor", "0.3.0"),
        ("1.0.4-rc.1", "major", "2.0.0"),
        ("1.1.0-rc.1", "major", "2.0.0"),
        ("1.1.4-rc.1", "major", "2.0.0"),
```
gsakkis: Here's a simplified implementation that passes all 12 cases, feel free to apply the diff:
```diff
diff --git a/semver.py b/semver.py
index 67ddded..294d369 100644
--- a/semver.py
+++ b/semver.py
@@ -715,24 +715,19 @@ def next_version(version, part, prerelease_token="rc"):
             )
         )
     version = VersionInfo.parse(version)
-    if part == "major" and version.prerelease:
-        return format_version(version.major, version.minor, version.patch)
-    elif part == "major" and not version.prerelease:
-        return str(version.bump_major())
-    elif part == "minor" and version.prerelease:
-        return format_version(version.major, version.minor, version.patch)
-    elif part == "minor" and not version.prerelease:
-        return str(version.bump_minor())
-    elif part == "patch" and version.prerelease:
-        return format_version(version.major, version.minor, version.patch)
-    elif part == "patch" and not version.prerelease:
-        return str(version.bump_patch())
-    elif part == "prerelease" and version.prerelease:
-        return str(version.bump_prerelease(token=prerelease_token))
-    elif part == "prerelease" and not version.prerelease:
-        return str(version.bump_patch().bump_prerelease(token=prerelease_token))
-
-    raise RuntimeError("Should not happen, got {part}".format(part=part))
+    if version.prerelease and (
+        part == "patch"
+        or (part == "minor" and version.patch == 0)
+        or (part == "major" and version.minor == version.patch == 0)
+    ):
+        return str(version.replace(prerelease=None))
+
+    if part in ("major", "minor", "patch"):
+        return str(getattr(version, "bump_" + part)())
+
+    if not version.prerelease:
+        version = version.bump_patch()
+    return str(version.bump_prerelease(prerelease_token))
 
 
 # ---- CLI
```
tomschr: @gsakkis Brilliant! :+1: Thanks for the contribution! I've included your patches. For the test cases, I've sorted them against the parts. Looks better. :wink: 
tomschr: I'm wondering, if we should check parts specifically for `build` and give a better error message why build cannot be used. At the moment, it raises a ValueErroor with an `Invalid part` message. In that specific case, it may confuse people.  I'd suggest to add:

```diff
diff --git i/semver.py w/semver.py
index b6443ae..5e69b1b 100644
--- i/semver.py
+++ w/semver.py
@@ -708,6 +708,8 @@ def next_version(version, part, prerelease_token="rc"):
         "prerelease",
         # "build", # ???
     }
+    if part == 'build':
+        raise ValueError("The build part is not predictable"))
```

I hadn't had a better idea for the error message. What do you think/prefer?
gsakkis: I don't mind the generic `Invalid part` message as it mentions which are the valid parts but I don't have a strong opinion either way, whatever you prefer.
tomschr: @gsakkis Ok, I have to think again what would be better.

Do you have anything else which needs to be covered in this function? If not, I would implement the rest and extend the documentation. It would be very helpful if you could review it. :wink: 
gsakkis: @tomschr would be nice to add the test cases mentioned [here](https://github.com/python-semver/python-semver/pull/222#issuecomment-590255225). With the current implementation they yield:
- ("0.2.0-rc.1", "patch", "0.2.0")  # same as "minor"
- ("1.0.0-rc.1", "patch", "1.0.0")  # same as "major"
- ("1.0.0-rc.1", "minor", "1.0.0") # same as "major"

Other than that LGTM too :+1: 
tomschr: @gsakkis Added your test cases. Thanks! :+1: 

After looking a bit closer to this class and this function, I realized there are a lot of similarities. As my findings are maybe a bit radical, I've opened a separate issue, see #225 for the details. :smile: 

For this PR, it isn't relevant.

tomschr: @gsakkis George, during documenting the function, I found something where I need your help.

Consider the following code example:

```py
>>> semver.next_version("3.4.5-pre.2+build.4", part="prerelease")  # (1)
'3.4.5-pre.3'
>>> semver.next_version("3.4.5-pre.2+build.4", part="patch")  # (2)
'3.4.5+build.4
>>> semver.next_version("3.4.5+build.4", part="patch")  # (3)
'3.4.6'
```

The first one is plausible, but what about the second one? Shouldn't it be `3.4.5`? What about the third one, should it be `3.4.5`?

I could remove the build part when I patch your version in the following line:

```diff
diff --git i/semver.py w/semver.py
index ac3bfd1..3acdfe8 100644
--- i/semver.py
+++ w/semver.py
@@ -733,7 +733,7 @@ def next_version(version, part, prerelease_token="rc"):
         or (part == "minor" and version.patch == 0)
         or (part == "major" and version.minor == version.patch == 0)
     ):
-        return str(version.replace(prerelease=None))
+        return str(version.replace(prerelease=None, build=None))
```

What do you think?

I guess, we run into these uncharted area of the build part. :wink: 
gsakkis: > Would that be plausible for you? What do you think?
> I guess, we run into these uncharted area of the build part. 
Indeed, afaict the rules regarding the build part are fuzzy but your changes look reasonable, at least more reasonable than preserving the build when bumping, so :shipit: 
tomschr: @gsakkis I just remembered, we should probably integrate the functionality into the CLI script `pysemver` as well, right?
tomschr: I've added now the new subcommand `nextver`:

```shell
$ pysemver nextver 1.2.3 prerelease
1.2.4-rc.1
```
tomschr: @gsakkis 
After looking of all issues, it seems I forgot this one. :cry:   Maybe it isn't as bad, as it could simplify the code a bit in the light of #229. Once this is merged, I would like to continue with that.

However, I would avoid a module level function `next_version`. Instead, I would integrate this as a method only function for the `VersionInfo` class.

My proposal would be:

```python
    def next_version(self, part:str, prerelease_token:str="rc"):
        """
        Determines the next version, taking prereleases into account.

        The "major", "minor", and "patch" raises the respective parts like
        the ``bump_*`` functions. The real difference is using the
        "preprelease" part. It gives you the next patch version of the prerelease,
        for example:

        >>> str(semver.VersionInfo.parse("0.1.4").next_version("prerelease"))
        '0.1.5-rc.1'

        :param part: One of "major", "minor", "patch", or "prerelease"
        :param prerelease_token: prefix string of prerelease, defaults to 'rc'
        :return: a new VersionInfo instance
        """
        validparts = {
            "major",
            "minor",
            "patch",
            "prerelease",
        }
        cls = type(self)
        if part not in validparts:
            raise ValueError(
                "Invalid part. Expected one of {validparts}, but got {part!r}".format(
                    validparts=validparts, part=part
                )
            )
        if (self.prerelease or self.build) and (
            part == "patch"
            or (part == "minor" and self.patch == 0)
            or (part == "major" and self.minor == self.patch == 0)
        ):
            newver = cls(*self.to_tuple())
            return newver.replace(prerelease=None, build=None)

        if part in ("major", "minor", "patch"):
            return getattr(self, "bump_" + part)()

        version = self
        if not self.prerelease:
            version = self.bump_patch()
        return version.bump_prerelease(prerelease_token)
```

I haven't tested it, but I thought, I write it down to document it.
gsakkis: > However, I would avoid a module level function `next_version`. Instead, I would integrate this as a method only function for the `VersionInfo` class. Would you be ok with that?

Yes absolutely, it makes sense.
tomschr: @gsakkis I've tried to rebase and adjust the changes. However, Python2 emits this error:

```shell
$ tox -e py27
[...]
      def iter_versions(self, firstpart, *parts, prerelease_token="rc"):                                                                                                                  
                                                                ^                                                                                                                         
  SyntaxError: invalid syntax
```

One solution would be to add this for Python3 only and leave Python2 behind. What would you think?
gsakkis: @tomschr did you mean to add this comment on #223? In any case the signature I proposed is Python 2 compatible: `def iter_versions(self, parts, prerelease_token="rc")`
tomschr: Ok, it seems I introduced something like `def next_version(self, *, parts, prerelease_token="rc")` which is only possible in Python3. You are right, your signature is Pytho2 compatible.

tomschr: @gsakkis I've fixed the conflicts and implemented the `next_version` in the `VersionInfo` class. A module level function is not available. Also adapted the test case.

I was wrong, it's not a Python3 only issue, so I removed this label. Maybe we could even incorporate it into the 2.10.0 release.

Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index 91e4d06..fd2af5f 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -244,8 +244,8 @@ It is possible to convert a :class:`semver.VersionInfo` instance:
     (5, 4, 2, None, None)
 
 
-Increasing Parts of a Version
------------------------------
+Raising Parts of a Version
+--------------------------
 
 The ``semver`` module contains the following functions to raise parts of
 a version:
@@ -276,6 +276,30 @@ a version:
 Likewise the module level functions :func:`semver.bump_major`.
 
 
+Increasing Parts of a Version Taking into Account Prereleases
+-------------------------------------------------------------
+
+.. versionadded:: 2.10.0
+   Added :func:`semver.VersionInfo.next_version`.
+
+If you want to raise your version and take prereleases into account,
+the function :func:`semver.VersionInfo.next_version` would perhaps a
+better fit.
+
+
+.. code-block:: python
+
+    >>> v = semver.VersionInfo.parse("3.4.5-pre.2+build.4")
+    >>> str(v.next_version(part="prerelease"))
+    '3.4.5-pre.3'
+    >>> str(semver.VersionInfo.parse("3.4.5-pre.2+build.4").next_version(part="patch"))
+    '3.4.5'
+    >>> str(semver.VersionInfo.parse("3.4.5+build.4").next_version(part="patch"))
+    '3.4.5'
+    >>> str(semver.VersionInfo.parse("0.1.4").next_version("prerelease"))
+    '0.1.5-rc.1'
+
+
 Comparing Versions
 ------------------
 
diff --git a/semver.py b/semver.py
index a5739b1..35192ed 100644
--- a/semver.py
+++ b/semver.py
@@ -422,6 +422,53 @@ build='build.10')
 
         return rccmp
 
+    def next_version(self, part, prerelease_token="rc"):
+        """
+        Determines next version, preserving natural order.
+
+        .. versionadded:: 2.10.0
+
+        This function is taking prereleases into account.
+        The "major", "minor", and "patch" raises the respective parts like
+        the ``bump_*`` functions. The real difference is using the
+        "preprelease" part. It gives you the next patch version of the prerelease,
+        for example:
+
+        >>> str(semver.VersionInfo.parse("0.1.4").next_version("prerelease"))
+        '0.1.5-rc.1'
+
+        :param part: One of "major", "minor", "patch", or "prerelease"
+        :param prerelease_token: prefix string of prerelease, defaults to 'rc'
+        :return:
+        """
+        validparts = {
+            "major",
+            "minor",
+            "patch",
+            "prerelease",
+            # "build", # currently not used
+        }
+        if part not in validparts:
+            raise ValueError(
+                "Invalid part. Expected one of {validparts}, but got {part!r}".format(
+                    validparts=validparts, part=part
+                )
+            )
+        version = self
+        if (version.prerelease or version.build) and (
+            part == "patch"
+            or (part == "minor" and version.patch == 0)
+            or (part == "major" and version.minor == version.patch == 0)
+        ):
+            return version.replace(prerelease=None, build=None)
+
+        if part in ("major", "minor", "patch"):
+            return str(getattr(version, "bump_" + part)())
+
+        if not version.prerelease:
+            version = version.bump_patch()
+        return version.bump_prerelease(prerelease_token)
+
     @comparator
     def __eq__(self, other):
         return self.compare(other) == 0
@@ -709,7 +756,10 @@ def max_ver(ver1, ver2):
     >>> semver.max_ver("1.0.0", "2.0.0")
     '2.0.0'
     """
-    ver1 = VersionInfo.parse(ver1)
+    if isinstance(ver1, str):
+        ver1 = VersionInfo.parse(ver1)
+    elif not isinstance(ver1, VersionInfo):
+        raise TypeError()
     cmp_res = ver1.compare(ver2)
     if cmp_res >= 0:
         return str(ver1)
@@ -898,6 +948,7 @@ def replace(version, **parts):
     return str(VersionInfo.parse(version).replace(**parts))
 
 
+# ---- CLI
 def cmd_bump(args):
     """
     Subcommand: Bumps a version.
@@ -953,6 +1004,19 @@ def cmd_compare(args):
     return str(compare(args.version1, args.version2))
 
 
+def cmd_nextver(args):
+    """
+    Subcommand: Determines the next version, taking prereleases into account.
+
+    Synopsis: nextver <VERSION> <PART>
+
+    :param args: The parsed arguments
+    :type args: :class:`argparse.Namespace`
+    """
+    version = VersionInfo.parse(args.version)
+    return str(version.next_version(args.part))
+
+
 def createparser():
     """
     Create an :class:`argparse.ArgumentParser` instance.
@@ -995,6 +1059,15 @@ def createparser():
     parser_check.set_defaults(func=cmd_check)
     parser_check.add_argument("version", help="Version to check")
 
+    # Create the nextver subcommand
+    parser_nextver = s.add_parser(
+        "nextver", help="Determines the next version, taking prereleases into account."
+    )
+    parser_nextver.set_defaults(func=cmd_nextver)
+    parser_nextver.add_argument("version", help="Version to raise")
+    parser_nextver.add_argument(
+        "part", help="One of 'major', 'minor', 'patch', or 'prerelease'"
+    )
     return parser
 
 


Test patch:
diff --git a/test_semver.py b/test_semver.py
index 5daf3f1..8ac42c8 100644
--- a/test_semver.py
+++ b/test_semver.py
@@ -881,3 +881,39 @@ def test_deprecated_deco_without_argument():
 
     with pytest.deprecated_call():
         assert mock_func()
+
+
+def test_next_version_with_invalid_parts():
+    version = VersionInfo.parse("1.0.1")
+    with pytest.raises(ValueError):
+        version.next_version("invalid")
+
+
+@pytest.mark.parametrize(
+    "version, part, expected",
+    [
+        # major
+        ("1.0.4-rc.1", "major", "2.0.0"),
+        ("1.1.0-rc.1", "major", "2.0.0"),
+        ("1.1.4-rc.1", "major", "2.0.0"),
+        ("1.2.3", "major", "2.0.0"),
+        ("1.0.0-rc.1", "major", "1.0.0"),
+        # minor
+        ("0.2.0-rc.1", "minor", "0.2.0"),
+        ("0.2.5-rc.1", "minor", "0.3.0"),
+        ("1.3.1", "minor", "1.4.0"),
+        # patch
+        ("1.3.2", "patch", "1.3.3"),
+        ("0.1.5-rc.2", "patch", "0.1.5"),
+        # prerelease
+        ("0.1.4", "prerelease", "0.1.5-rc.1"),
+        ("0.1.5-rc.1", "prerelease", "0.1.5-rc.2"),
+        # special cases
+        ("0.2.0-rc.1", "patch", "0.2.0"),  # same as "minor"
+        ("1.0.0-rc.1", "patch", "1.0.0"),  # same as "major"
+        ("1.0.0-rc.1", "minor", "1.0.0"),  # same as "major"
+    ],
+)
+def test_next_version_with_versioninfo(version, part, expected):
+    ver = VersionInfo.parse(version)
+    assert str(ver.next_version(part)) == expected