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

Issue:
Replace method not compatible with SubClasses
<!-- Remove the comments below and adapt it to your needs  -->

# Situation
<!-- A clear and concise description of what the bug is. -->

Using the demonstration subclass from https://python-semver.readthedocs.io/en/latest/advanced/create-subclasses-from-version.html#creating-subclasses-from-version, the bump methods will create a new instance of the same SubClass.

When using the `replace` method, an instance of the `Version` class is created and not the subclass.

# To Reproduce
<!--
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->

1. Create a subclass as shown in the document above.
2. `version = SemVerWithVPrefix.parse("v1.1.0")`
3. `dev_version = version.replace(prerelease="dev.0")`
4. Print `str(dev_version)`
5. This results in `1.1.0-dev.0` - dev_version is of type `Version` not `SemVerWithVPrefix`.

# Expected Behavior
<!-- A clear and concise description of what you expected to happen. -->

I expect `v1.1.0-dev.0` and `dev_version` to be an instance of SemVerWithVPrefix (or my class).

# Environment
- OS: [e.g. Linux, MacOS, Windows, ...] - Python 3.11 container - will be Debian.
- Python version [e.g. 3.6, 3.7, ...] - Python 3.11.5
- Version of semver library [e.g. 3.0.0] - semver==3.0.1

# Additional context
<!-- Add any other context about the problem here. -->

I suggest changing this the same way that the bump methods work:
Add this near the top: `cls = type(self)`
And near the bottom, replace `return Version(**version)` with `return cls(**version)`.


Pull request description:
Fix #426: call subclass when deriving from Version
This fixes PR #426 

When using the replace method, an instance of the Version class is created and not the subclass. This commit fixes this behavior.

@dannystaple: Could you have a look, please? :slightly_smiling_face: 

(The CI has currently problems. I'm currently investigating this.)

Patch:
diff --git a/changelog.d/426.bugfix.rst b/changelog.d/426.bugfix.rst
new file mode 100644
index 0000000..2c2d0ea
--- /dev/null
+++ b/changelog.d/426.bugfix.rst
@@ -0,0 +1,2 @@
+Fix :meth:`~semver.version.Version.replace` method to use the derived class
+of an instance instead of :class:`~semver.version.Version` class.
diff --git a/src/semver/version.py b/src/semver/version.py
index 9a7a4ff..29309ab 100644
--- a/src/semver/version.py
+++ b/src/semver/version.py
@@ -655,8 +655,8 @@ prerelease='pre.2', build='build.4')
 
     def replace(self, **parts: Union[int, Optional[str]]) -> "Version":
         """
-        Replace one or more parts of a version and return a new
-        :class:`Version` object, but leave self untouched
+        Replace one or more parts of a version and return a new :class:`Version`
+        object, but leave self untouched.
 
         .. versionadded:: 2.9.0
            Added :func:`Version.replace`
@@ -670,7 +670,7 @@ prerelease='pre.2', build='build.4')
         version = self.to_dict()
         version.update(parts)
         try:
-            return Version(**version)  # type: ignore
+            return type(self)(**version)  # type: ignore
         except TypeError:
             unknownkeys = set(parts) - set(self.to_dict())
             error = "replace() got %d unexpected keyword argument(s): %s" % (


Test patch:
diff --git a/.github/workflows/python-testing.yml b/.github/workflows/python-testing.yml
index f6decc0..adb06d4 100644
--- a/.github/workflows/python-testing.yml
+++ b/.github/workflows/python-testing.yml
@@ -60,7 +60,7 @@ jobs:
           cache: 'pip'
       - name: Install dependencies
         run: |
-          python3 -m pip install --upgrade pip setuptools setuptools-scm
+          python3 -m pip install --upgrade pip setuptools>60 setuptools-scm>=60
           pip install tox tox-gh-actions
       - name: Check
         run: |
diff --git a/tests/test_subclass.py b/tests/test_subclass.py
index cbf9d27..b33f496 100644
--- a/tests/test_subclass.py
+++ b/tests/test_subclass.py
@@ -17,3 +17,37 @@ def test_subclass_from_versioninfo():
 
     v = SemVerWithVPrefix.parse("v1.2.3")
     assert str(v) == "v1.2.3"
+
+
+def test_replace_from_subclass():
+    # Issue#426
+    # Taken from the example "Creating Subclasses from Version"
+    class SemVerWithVPrefix(Version):
+        """
+        A subclass of Version which allows a "v" prefix
+        """
+
+        @classmethod
+        def parse(cls, version: str) -> "SemVerWithVPrefix":
+            """
+            Parse version string to a Version instance.
+
+            :param version: version string with "v" or "V" prefix
+            :raises ValueError: when version does not start with "v" or "V"
+            :return: a new instance
+            """
+            if not version[0] in ("v", "V"):
+                raise ValueError(
+                    f"{version!r}: not a valid semantic version tag. "
+                    "Must start with 'v' or 'V'"
+                )
+            return super().parse(version[1:], optional_minor_and_patch=True)
+
+        def __str__(self) -> str:
+            # Reconstruct the tag
+            return "v" + super().__str__()
+
+    version = SemVerWithVPrefix.parse("v1.1.0")
+    dev_version = version.replace(prerelease="dev.0")
+
+    assert str(dev_version) == "v1.1.0-dev.0"