Repository: pypa/setuptools_scm

Issue:
Incorrect removal of dash in case of pre-release tag string ?
I found this today: `1.0.0-rc1` becomes `1.0.0rc1`, see details here https://github.com/smarie/python-getversion/issues/10

The underlying error is due to `pkg_resources` that removes the pre-release dash automatically in its string representation since version [0.6a9](https://setuptools.readthedocs.io/en/latest/pkg_resources.html#0.6a9).

However as suggested in [my conclusions](https://github.com/smarie/python-getversion/issues/10#issuecomment-784987499) I think that the issue should probably better be fixed in the default scheme of setuptools_scm because faithfulness to git tags and compliance with `semver` for example, is probably a higher concern for you than for `pkg_resources`.

Thanks again for this great package !

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ecbcf81..581e3a9 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,8 @@
+In progress
+===========
+
+* fix #524: new parameters ``normalize`` and ``version_cls`` to customize the version normalization class.
+
 v6.0.1
 =======
 
diff --git a/README.rst b/README.rst
index 205dc99..ee9625b 100644
--- a/README.rst
+++ b/README.rst
@@ -376,6 +376,30 @@ The currently supported configuration keys are:
     Defaults to the value set by ``setuptools_scm.git.DEFAULT_DESCRIBE``
     (see `git.py <src/setuptools_scm/git.py>`_).
 
+:normalize:
+    A boolean flag indicating if the version string should be normalized.
+    Defaults to ``True``. Setting this to ``False`` is equivalent to setting
+    ``version_cls`` to ``setuptools_scm.version.NonNormalizedVersion``
+
+:version_cls:
+    An optional class used to parse, verify and possibly normalize the version
+    string. Its constructor should receive a single string argument, and its
+    ``str`` should return the normalized version string to use.
+    This option can also receive a class qualified name as a string.
+
+    This defaults to ``packaging.version.Version`` if available. If
+    ``packaging`` is not installed, ``pkg_resources.packaging.version.Version``
+    is used. Note that it is known to modify git release candidate schemes.
+
+    The ``setuptools_scm.NonNormalizedVersion`` convenience class is
+    provided to disable the normalization step done by
+    ``packaging.version.Version``. If this is used while ``setuptools_scm``
+    is integrated in a setuptools packaging process, the non-normalized
+    version number will appear in all files (see ``write_to``) BUT note
+    that setuptools will still normalize it to create the final distribution,
+    so as to stay compliant with the python packaging standards.
+
+
 To use ``setuptools_scm`` in other Python code you can use the ``get_version``
 function:
 
diff --git a/src/setuptools_scm/__init__.py b/src/setuptools_scm/__init__.py
index 97061f2..e874aaa 100644
--- a/src/setuptools_scm/__init__.py
+++ b/src/setuptools_scm/__init__.py
@@ -15,6 +15,7 @@ from .config import (
     DEFAULT_VERSION_SCHEME,
     DEFAULT_LOCAL_SCHEME,
     DEFAULT_TAG_REGEX,
+    NonNormalizedVersion,
 )
 from .utils import function_has_arg, trace
 from .version import format_version, meta
@@ -159,6 +160,8 @@ def get_version(
     parse=None,
     git_describe_command=None,
     dist_name=None,
+    version_cls=None,
+    normalize=True,
 ):
     """
     If supplied, relative_to should be a file from which root may
@@ -188,3 +191,22 @@ def _get_version(config):
         )
 
         return version_string
+
+
+# Public API
+__all__ = [
+    "get_version",
+    "dump_version",
+    "version_from_scm",
+    "Configuration",
+    "NonNormalizedVersion",
+    "DEFAULT_VERSION_SCHEME",
+    "DEFAULT_LOCAL_SCHEME",
+    "DEFAULT_TAG_REGEX",
+    # TODO: are the symbols below part of public API ?
+    "function_has_arg",
+    "trace",
+    "format_version",
+    "meta",
+    "iter_matching_entrypoints",
+]
diff --git a/src/setuptools_scm/config.py b/src/setuptools_scm/config.py
index 78c7fc2..443f1f4 100644
--- a/src/setuptools_scm/config.py
+++ b/src/setuptools_scm/config.py
@@ -3,6 +3,14 @@ import os
 import re
 import warnings
 
+try:
+    from packaging.version import Version
+except ImportError:
+    import pkg_resources
+
+    Version = pkg_resources.packaging.version.Version
+
+
 from .utils import trace
 
 DEFAULT_TAG_REGEX = r"^(?:[\w-]+-)?(?P<version>[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$"
@@ -65,6 +73,8 @@ class Configuration:
         parse=None,
         git_describe_command=None,
         dist_name=None,
+        version_cls=None,
+        normalize=True,
     ):
         # TODO:
         self._relative_to = relative_to
@@ -83,6 +93,30 @@ class Configuration:
         self.git_describe_command = git_describe_command
         self.dist_name = dist_name
 
+        if not normalize:
+            # `normalize = False` means `version_cls = NonNormalizedVersion`
+            if version_cls is not None:
+                raise ValueError(
+                    "Providing a custom `version_cls` is not permitted when "
+                    "`normalize=False`"
+                )
+            self.version_cls = NonNormalizedVersion
+        else:
+            # Use `version_cls` if provided, default to packaging or pkg_resources
+            if version_cls is None:
+                version_cls = Version
+            elif isinstance(version_cls, str):
+                try:
+                    # Not sure this will work in old python
+                    import importlib
+
+                    pkg, cls_name = version_cls.rsplit(".", 1)
+                    version_cls_host = importlib.import_module(pkg)
+                    version_cls = getattr(version_cls_host, cls_name)
+                except:  # noqa
+                    raise ValueError(f"Unable to import version_cls='{version_cls}'")
+            self.version_cls = version_cls
+
     @property
     def fallback_root(self):
         return self._fallback_root
@@ -137,3 +171,28 @@ class Configuration:
             defn = __import__("toml").load(strm)
         section = defn.get("tool", {})["setuptools_scm"]
         return cls(dist_name=dist_name, **section)
+
+
+class NonNormalizedVersion(Version):
+    """A non-normalizing version handler.
+
+    You can use this class to preserve version verification but skip normalization.
+    For example you can use this to avoid git release candidate version tags
+    ("1.0.0-rc1") to be normalized to "1.0.0rc1". Only use this if you fully
+    trust the version tags.
+    """
+
+    def __init__(self, version):
+        # parse and validate using parent
+        super().__init__(version)
+
+        # store raw for str
+        self._raw_version = version
+
+    def __str__(self):
+        # return the non-normalized version (parent returns the normalized)
+        return self._raw_version
+
+    def __repr__(self):
+        # same pattern as parent
+        return f"<NonNormalizedVersion({self._raw_version!r})>"
diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py
index befa0dd..3ed5ac1 100644
--- a/src/setuptools_scm/version.py
+++ b/src/setuptools_scm/version.py
@@ -4,16 +4,9 @@ import re
 import time
 import os
 
-from .config import Configuration
+from .config import Configuration, Version as PkgVersion
 from .utils import trace, iter_entry_points
 
-try:
-    from packaging.version import Version
-except ImportError:
-    import pkg_resources
-
-    Version = pkg_resources.packaging.version.Version
-
 
 SEMVER_MINOR = 2
 SEMVER_PATCH = 3
@@ -77,7 +70,7 @@ def tag_to_version(tag, config=None):
             )
         )
 
-    version = Version(version)
+    version = config.version_cls(version)
     trace("version", repr(version))
 
     return version
@@ -168,7 +161,7 @@ class ScmVersion:
 def _parse_tag(tag, preformatted, config):
     if preformatted:
         return tag
-    if not isinstance(tag, Version):
+    if not isinstance(tag, config.version_cls):
         tag = tag_to_version(tag, config)
     return tag
 
@@ -319,7 +312,7 @@ def date_ver_match(ver):
     return match
 
 
-def guess_next_date_ver(version, node_date=None, date_fmt=None):
+def guess_next_date_ver(version, node_date=None, date_fmt=None, version_cls=None):
     """
     same-day -> patch +1
     other-day -> today
@@ -354,8 +347,9 @@ def guess_next_date_ver(version, node_date=None, date_fmt=None):
         node_date=head_date, date_fmt=date_fmt, patch=patch
     )
     # rely on the Version object to ensure consistency (e.g. remove leading 0s)
-    # TODO: support for intentionally non-normalized date versions
-    next_version = str(Version(next_version))
+    if version_cls is None:
+        version_cls = PkgVersion
+    next_version = str(version_cls(next_version))
     return next_version
 
 
@@ -370,7 +364,11 @@ def calver_by_date(version):
             match = date_ver_match(ver)
             if match:
                 return ver
-    return version.format_next_version(guess_next_date_ver, node_date=version.node_date)
+    return version.format_next_version(
+        guess_next_date_ver,
+        node_date=version.node_date,
+        version_cls=version.config.version_cls,
+    )
 
 
 def _format_local_with_time(version, time_format):


Test patch:
diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py
index 5aaf8fa..3e56ea5 100644
--- a/testing/test_basic_api.py
+++ b/testing/test_basic_api.py
@@ -135,3 +135,24 @@ def test_parse_plain_fails(recwarn):
 
     with pytest.raises(TypeError):
         setuptools_scm.get_version(parse=parse)
+
+
+def test_custom_version_cls():
+    """Test that `normalize` and `version_cls` work as expected"""
+
+    class MyVersion:
+        def __init__(self, tag_str: str):
+            self.version = tag_str
+
+        def __repr__(self):
+            return f"hello,{self.version}"
+
+    # you can not use normalize=False and version_cls at the same time
+    with pytest.raises(ValueError):
+        setuptools_scm.get_version(normalize=False, version_cls=MyVersion)
+
+    # TODO unfortunately with PRETEND_KEY the preformatted flag becomes True
+    #  which bypasses our class. which other mechanism would be ok to use here
+    #  to create a test?
+    # monkeypatch.setenv(setuptools_scm.PRETEND_KEY, "1.0.1")
+    # assert setuptools_scm.get_version(version_cls=MyVersion) == "1"
diff --git a/testing/test_git.py b/testing/test_git.py
index cc5ef53..40c5e99 100644
--- a/testing/test_git.py
+++ b/testing/test_git.py
@@ -1,15 +1,15 @@
 import sys
 import os
-from setuptools_scm import integration
-from setuptools_scm.utils import do, has_command
-from setuptools_scm import git
-import pytest
-from datetime import datetime
 from os.path import join as opj
-from setuptools_scm.file_finder_git import git_find_files
-from datetime import date
+import pytest
+from datetime import datetime, date
 from unittest.mock import patch, Mock
 
+from setuptools_scm import integration, git, NonNormalizedVersion
+from setuptools_scm.utils import do, has_command
+from setuptools_scm.file_finder_git import git_find_files
+
+
 pytestmark = pytest.mark.skipif(
     not has_command("git", warn=False), reason="git executable not found"
 )
@@ -104,6 +104,70 @@ def test_version_from_git(wd):
     wd("git tag 17.33.0-rc")
     assert wd.version == "17.33.0rc0"
 
+    # custom normalization
+    assert wd.get_version(normalize=False) == "17.33.0-rc"
+    assert wd.get_version(version_cls=NonNormalizedVersion) == "17.33.0-rc"
+    assert (
+        wd.get_version(version_cls="setuptools_scm.NonNormalizedVersion")
+        == "17.33.0-rc"
+    )
+
+
+@pytest.mark.parametrize("with_class", [False, type, str])
+def test_git_version_unnormalized_setuptools(with_class, tmpdir, wd, monkeypatch):
+    """
+    Test that when integrating with setuptools without normalization,
+    the version is not normalized in write_to files,
+    but still normalized by setuptools for the final dist metadata.
+    """
+    monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG")
+    p = wd.cwd
+
+    # create a setup.py
+    dest_file = str(tmpdir.join("VERSION.txt")).replace("\\", "/")
+    if with_class is False:
+        # try normalize = False
+        setup_py = """
+from setuptools import setup
+setup(use_scm_version={'normalize': False, 'write_to': '%s'})
+"""
+    elif with_class is type:
+        # custom non-normalizing class
+        setup_py = """
+from setuptools import setup
+
+class MyVersion:
+    def __init__(self, tag_str: str):
+        self.version = tag_str
+
+    def __repr__(self):
+        return self.version
+
+setup(use_scm_version={'version_cls': MyVersion, 'write_to': '%s'})
+"""
+    elif with_class is str:
+        # non-normalizing class referenced by name
+        setup_py = """from setuptools import setup
+setup(use_scm_version={
+    'version_cls': 'setuptools_scm.NonNormalizedVersion',
+    'write_to': '%s'
+})
+"""
+
+    # finally write the setup.py file
+    p.joinpath("setup.py").write_text(setup_py % dest_file)
+
+    # do git operations and tag
+    wd.commit_testfile()
+    wd("git tag 17.33.0-rc1")
+
+    # setuptools still normalizes using packaging.Version (removing the dash)
+    res = do((sys.executable, "setup.py", "--version"), p)
+    assert res == "17.33.0rc1"
+
+    # but the version tag in the file is non-normalized (with the dash)
+    assert tmpdir.join("VERSION.txt").read() == "17.33.0-rc1"
+
 
 @pytest.mark.issue(179)
 def test_unicode_version_scheme(wd):
diff --git a/testing/test_version.py b/testing/test_version.py
index 9984a5d..eda22c8 100644
--- a/testing/test_version.py
+++ b/testing/test_version.py
@@ -283,3 +283,19 @@ def test_calver_by_date_semver(version, expected_next):
 def test_calver_by_date_future_warning():
     with pytest.warns(UserWarning, match="your previous tag*"):
         calver_by_date(meta(date_to_str(days_offset=-2), config=c, distance=2))
+
+
+def test_custom_version_cls():
+    """Test that we can pass our own version class instead of pkg_resources"""
+
+    class MyVersion:
+        def __init__(self, tag_str: str):
+            self.tag = tag_str
+
+        def __repr__(self):
+            return "Custom %s" % self.tag
+
+    scm_version = meta("1.0.0-foo", config=Configuration(version_cls=MyVersion))
+
+    assert isinstance(scm_version.tag, MyVersion)
+    assert repr(scm_version.tag) == "Custom 1.0.0-foo"