Repository: pypa/setuptools_scm
Pull request #276 (2018-06-30 23:56:13)

configurable version number extraction from tag strings
As suggested in #275 a modification to use regex to extract the version information from tags. Regex can be changed from the configuration to adapt to tagging schemes more flexible.

Extracted the whole configuration to a singleton class to have it available without passing all params through the stack and reduce the number of changes. Don't think there will be situations where different configurations would be used at the same time, do you?

Tested on Ubuntu using 2.7 and 3.6.

What do you think?


Patch:
diff --git a/README.rst b/README.rst
index 6d4a139..4765803 100644
--- a/README.rst
+++ b/README.rst
@@ -156,7 +156,7 @@ Builtin mechanisms for obtaining version numbers
 
 
 Configuration Parameters
-------------------------------
+------------------------
 
 In order to configure the way ``use_scm_version`` works you can provide
 a mapping with options instead of a boolean value.
@@ -202,6 +202,14 @@ The currently supported configuration keys are:
   Use with caution, this is a function for advanced use, and you should be
   familiar with the setuptools_scm internals to use it.
 
+:tag_regex:
+  A python regex string to extract the version part from any SCM tag.
+  The regex needs to contain three named groups prefix, version and suffix,
+  where `version` captures the actual version information.
+
+  defaults to the value of ``setuptools_scm.config.DEFAULT_TAG_REGEX``
+  (see `config.py <src/setuptools_scm/config.py>`_).
+
 
 To use setuptools_scm in other Python code you can use the
 ``get_version`` function:
@@ -214,6 +222,19 @@ To use setuptools_scm in other Python code you can use the
 It optionally accepts the keys of the ``use_scm_version`` parameter as
 keyword arguments.
 
+Example configuration in `setup.py` format:
+
+.. code:: python
+
+    from setuptools import setup
+
+
+    setup(
+        use_scm_version={
+            'write_to': 'version.txt',
+            'tag_regex': r'^(?P<prefix>v)?(?P<version>[^\+]+)(?P<suffix>.*)?$',
+        }
+    )
 
 Environment Variables
 ---------------------
diff --git a/src/setuptools_scm/__init__.py b/src/setuptools_scm/__init__.py
index b256289..c7a2878 100644
--- a/src/setuptools_scm/__init__.py
+++ b/src/setuptools_scm/__init__.py
@@ -3,15 +3,15 @@
 :license: MIT
 """
 import os
-import sys
+import warnings
 
-from .utils import trace
+from .config import Configuration
+from .utils import function_has_arg, string_types
 from .version import format_version, meta
 from .discover import iter_matching_entrypoints
 
 PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION"
 
-
 TEMPLATES = {
     ".py": """\
 # coding: utf-8
@@ -22,17 +22,24 @@ version = {version!r}
     ".txt": "{version}",
 }
 
-PY3 = sys.version_info > (3,)
-string_types = (str,) if PY3 else (str, unicode)  # noqa
-
 
 def version_from_scm(root):
+    # TODO: Is it API?
     return _version_from_entrypoint(root, "setuptools_scm.parse_scm")
 
 
-def _version_from_entrypoint(root, entrypoint):
-    for ep in iter_matching_entrypoints(root, entrypoint):
-        version = ep.load()(root)
+def _call_entrypoint_fn(config, fn):
+    if function_has_arg(fn, 'config'):
+        return fn(config.absolute_root, config=config)
+    else:
+        warnings.warn("parse functions are required to provide a named argument 'config' in the future.", PendingDeprecationWarning)
+        return fn(config.absolute_root)
+
+
+def _version_from_entrypoint(config, entrypoint):
+    for ep in iter_matching_entrypoints(config.absolute_root, entrypoint):
+        version = _call_entrypoint_fn(config, ep.load())
+
         if version:
             return version
 
@@ -55,27 +62,26 @@ def dump_version(root, version, write_to, template=None):
         fp.write(template.format(version=version))
 
 
-def _do_parse(root, parse):
+def _do_parse(config):
     pretended = os.environ.get(PRETEND_KEY)
     if pretended:
         # we use meta here since the pretended version
         # must adhere to the pep to begin with
         return meta(pretended)
 
-    if parse:
-        parse_result = parse(root)
+    if config.parse:
+        parse_result = _call_entrypoint_fn(config, config.parse)
         if isinstance(parse_result, string_types):
             raise TypeError(
                 "version parse result was a string\nplease return a parsed version"
             )
-        version = parse_result or _version_from_entrypoint(
-            root, "setuptools_scm.parse_scm_fallback"
-        )
+        version = parse_result or \
+            _version_from_entrypoint(config, "setuptools_scm.parse_scm_fallback")
+    
     else:
         # include fallbacks after dropping them from the main entrypoint
-        version = version_from_scm(root) or _version_from_entrypoint(
-            root, "setuptools_scm.parse_scm_fallback"
-        )
+        version = _version_from_entrypoint(config, "setuptools_scm.parse_scm") or \
+            _version_from_entrypoint(config, "setuptools_scm.parse_scm_fallback")
 
     if version:
         return version
@@ -88,7 +94,7 @@ def _do_parse(root, parse):
         "metadata and will not work.\n\n"
         "For example, if you're using pip, instead of "
         "https://github.com/user/proj/archive/master.zip "
-        "use git+https://github.com/user/proj.git#egg=proj" % root
+        "use git+https://github.com/user/proj.git#egg=proj" % config.absolute_root
     )
 
 
@@ -99,6 +105,7 @@ def get_version(
     write_to=None,
     write_to_template=None,
     relative_to=None,
+    tag_regex=None,
     parse=None,
 ):
     """
@@ -107,12 +114,18 @@ def get_version(
     in the root of the repository to direct setuptools_scm to the
     root of the repository by supplying ``__file__``.
     """
-    if relative_to:
-        root = os.path.join(os.path.dirname(relative_to), root)
-    root = os.path.abspath(root)
-    trace("root", repr(root))
-
-    parsed_version = _do_parse(root, parse)
+    
+    config = Configuration()
+    config.root = root
+    config.version_scheme = version_scheme
+    config.local_scheme = local_scheme
+    config.write_to = write_to
+    config.write_to_template = write_to_template
+    config.relative_to = relative_to
+    config.tag_regex = tag_regex
+    config.parse = parse
+
+    parsed_version = _do_parse(config)
 
     if parsed_version:
         version_string = format_version(
diff --git a/src/setuptools_scm/config.py b/src/setuptools_scm/config.py
new file mode 100644
index 0000000..dbd3768
--- /dev/null
+++ b/src/setuptools_scm/config.py
@@ -0,0 +1,92 @@
+""" configuration """
+from __future__ import print_function, unicode_literals
+import os
+import re
+import warnings
+
+from .utils import trace
+
+DEFAULT_TAG_REGEX = r'^(?:\w+-)?(?P<version>v?\d+(?:\.\d+){0,2}[^\+]+)(?:\+.*)?$'
+DEFAULT_VERSION_SCHEME = 'version_scheme'
+
+
+def _check_tag_regex(value):
+    if not value:
+        value = DEFAULT_TAG_REGEX
+    regex = re.compile(value)
+
+    group_names = regex.groupindex.keys()
+    if regex.groups == 0 or (regex.groups > 1 and 'version' not in group_names):
+        warnings.warn("Expected tag_regex to contain a single match group or a group named 'version' " +
+                      "to identify the version part of any tag.")
+
+    return regex
+
+
+def _check_absolute_root(root, relative_to):
+    if relative_to:
+        if os.path.isabs(root) and not root.startswith(relative_to):
+            warnings.warn("absolute root path '%s' overrides relative_to '%s'" % (root, relative_to))
+        root = os.path.join(os.path.dirname(relative_to), root)
+    return os.path.abspath(root)
+
+
+class Configuration(object):
+    """ Global configuration model """
+
+    _root = None
+    version_scheme = None
+    local_scheme = None
+    write_to = None
+    write_to_template = None
+    _relative_to = None
+    parse = None
+    _tag_regex = None
+    _absolute_root = None
+
+    def __init__(self,
+                 relative_to=None,
+                 root='.'):
+        # TODO:
+        self._relative_to = relative_to
+        self._root = '.'
+
+        self.root = root
+        self.version_scheme = DEFAULT_VERSION_SCHEME
+        self.local_scheme = "node-and-date"
+        self.write_to = ''
+        self.write_to_template = None
+        self.parse = None
+        self.tag_regex = DEFAULT_TAG_REGEX
+
+    @property
+    def absolute_root(self):
+        return self._absolute_root
+
+    @property
+    def relative_to(self):
+        return self._relative_to
+
+    @relative_to.setter
+    def relative_to(self, value):
+        self._absolute_root = _check_absolute_root(self._root, value)
+        self._relative_to = value
+        trace("root", repr(self._absolute_root))
+
+    @property
+    def root(self):
+        return self._root
+
+    @root.setter
+    def root(self, value):
+        self._absolute_root = _check_absolute_root(value, self._relative_to)
+        self._root = value
+        trace("root", repr(self._absolute_root))
+
+    @property
+    def tag_regex(self):
+        return self._tag_regex
+
+    @tag_regex.setter
+    def tag_regex(self, value):
+        self._tag_regex = _check_tag_regex(value)
diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py
index 7c45a84..07b976b 100644
--- a/src/setuptools_scm/git.py
+++ b/src/setuptools_scm/git.py
@@ -1,3 +1,4 @@
+from .config import Configuration
 from .utils import do_ex, trace, has_command
 from .version import meta
 
@@ -82,20 +83,23 @@ def fail_on_shallow(wd):
         )
 
 
-def parse(root, describe_command=DEFAULT_DESCRIBE, pre_parse=warn_on_shallow):
+def parse(root, config=None, describe_command=DEFAULT_DESCRIBE, pre_parse=warn_on_shallow):
     """
     :param pre_parse: experimental pre_parse action, may change at any time
     """
+    if not config:
+        config = Configuration(root=root)
+
     if not has_command("git"):
         return
 
-    wd = GitWorkdir.from_potential_worktree(root)
+    wd = GitWorkdir.from_potential_worktree(config.absolute_root)
     if wd is None:
         return
     if pre_parse:
         pre_parse(wd)
 
-    out, err, ret = wd.do_ex(describe_command)
+    out, unused_err, ret = wd.do_ex(describe_command)
     if ret:
         # If 'git describe' failed, try to get the information otherwise.
         rev_node = wd.node()
@@ -116,9 +120,9 @@ def parse(root, describe_command=DEFAULT_DESCRIBE, pre_parse=warn_on_shallow):
 
         branch = wd.get_branch()
         if number:
-            return meta(tag, distance=number, node=node, dirty=dirty, branch=branch)
+            return meta(tag, config=config, distance=number, node=node, dirty=dirty, branch=branch)
         else:
-            return meta(tag, node=node, dirty=dirty, branch=branch)
+            return meta(tag, config=config, node=node, dirty=dirty, branch=branch)
 
 
 def _git_parse_describe(describe_output):
diff --git a/src/setuptools_scm/hg.py b/src/setuptools_scm/hg.py
index 876a784..b76f2d9 100644
--- a/src/setuptools_scm/hg.py
+++ b/src/setuptools_scm/hg.py
@@ -1,4 +1,5 @@
 import os
+from .config import Configuration
 from .utils import do, trace, data_from_mime, has_command
 from .version import meta, tags_to_versions
 
@@ -28,10 +29,13 @@ def _hg_tagdist_normalize_tagcommit(root, tag, dist, node, branch):
         return meta(tag)
 
 
-def parse(root):
+def parse(root, config=None):
+    if not config:
+        config = Configuration(root=root)
+
     if not has_command("hg"):
         return
-    identity_data = do("hg id -i -b -t", root).split()
+    identity_data = do("hg id -i -b -t", config.absolute_root).split()
     if not identity_data:
         return
     node = identity_data.pop(0)
@@ -44,16 +48,16 @@ def parse(root):
         return meta(tags[0], dirty=dirty, branch=branch)
 
     if node.strip("+") == "0" * 12:
-        trace("initial node", root)
-        return meta("0.0", dirty=dirty, branch=branch)
+        trace("initial node", config.absolute_root)
+        return meta("0.0", config=config, dirty=dirty, branch=branch)
 
     try:
-        tag = get_latest_normalizable_tag(root)
-        dist = get_graph_distance(root, tag)
+        tag = get_latest_normalizable_tag(config.absolute_root)
+        dist = get_graph_distance(config.absolute_root, tag)
         if tag == "null":
             tag = "0.0"
             dist = int(dist) + 1
-        return _hg_tagdist_normalize_tagcommit(root, tag, dist, node, branch)
+        return _hg_tagdist_normalize_tagcommit(config.absolute_root, tag, dist, node, branch)
     except ValueError:
         pass  # unpacking failed, old hg
 
diff --git a/src/setuptools_scm/utils.py b/src/setuptools_scm/utils.py
index e6b767c..7e4e9f5 100644
--- a/src/setuptools_scm/utils.py
+++ b/src/setuptools_scm/utils.py
@@ -2,6 +2,7 @@
 utils
 """
 from __future__ import print_function, unicode_literals
+import inspect
 import warnings
 import sys
 import shlex
@@ -14,6 +15,8 @@ import platform
 DEBUG = bool(os.environ.get("SETUPTOOLS_SCM_DEBUG"))
 IS_WINDOWS = platform.system() == "Windows"
 PY2 = sys.version_info < (3,)
+PY3 = sys.version_info > (3,)
+string_types = (str,) if PY3 else (str, unicode)  # noqa
 
 
 def trace(*k):
@@ -91,6 +94,17 @@ def data_from_mime(path):
     return data
 
 
+def function_has_arg(fn, argname):
+    assert inspect.isfunction(fn)
+
+    if PY2:
+        argspec = inspect.getargspec(fn).args
+    else:
+        argspec = inspect.getfullargspec(fn).args
+
+    return argname in argspec
+
+
 def has_command(name):
     try:
         p = _popen_pipes([name, "help"], ".")
diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py
index 5799f1a..47ad6d3 100644
--- a/src/setuptools_scm/version.py
+++ b/src/setuptools_scm/version.py
@@ -4,7 +4,8 @@ import warnings
 import re
 from itertools import chain, repeat, islice
 
-from .utils import trace
+from .config import Configuration
+from .utils import trace, string_types
 
 from pkg_resources import iter_entry_points
 
@@ -13,7 +14,6 @@ from pkg_resources import parse_version as pkg_parse_version
 SEMVER_MINOR = 2
 SEMVER_PATCH = 3
 SEMVER_LEN = 3
-TAG_PREFIX = re.compile(r"^\w+-(.*)")
 
 
 def _pad(iterable, size, padding=None):
@@ -21,6 +21,27 @@ def _pad(iterable, size, padding=None):
     return list(islice(padded, size))
 
 
+def _parse_version_tag(tag, config):
+    tagstring = tag if not isinstance(tag, string_types) else str(tag)
+    match = config.tag_regex.match(tagstring)
+
+    result = None
+    if match:
+        if len(match.groups()) == 1:
+            key = 1
+        else:
+            key = 'version'
+        
+        result = {
+            'version': match.group(key),
+            'prefix': match.group(0)[:match.start(key)],
+            'suffix': match.group(0)[match.end(key):],
+        }
+
+    trace("tag '%s' parsed to %s" % (tag, result))
+    return result
+
+
 def _get_version_class():
     modern_version = pkg_parse_version("1.0")
     if isinstance(modern_version, tuple):
@@ -56,33 +77,41 @@ def callable_or_entrypoint(group, callable_or_name):
         return ep.load()
 
 
-def tag_to_version(tag):
+def tag_to_version(tag, config=None):
     """
     take a tag that might be prefixed with a keyword and return only the version part
+    :param config: optional configuration object
     """
     trace("tag", tag)
-    if "+" in tag:
-        warnings.warn("tag %r will be stripped of the local component" % tag)
-        tag = tag.split("+")[0]
-    # lstrip the v because of py2/py3 differences in setuptools
-    # also required for old versions of setuptools
-    prefix_match = TAG_PREFIX.match(tag)
-    if prefix_match is not None:
-        version = prefix_match.group(1)
-    else:
-        version = tag
+
+    if not config:
+        config = Configuration()
+
+    tagdict = _parse_version_tag(tag, config)
+    if not isinstance(tagdict, dict) or not tagdict.get('version', None):
+        warnings.warn("tag %r no version found" % (tag,))
+        return None
+
+    version = tagdict['version']
     trace("version pre parse", version)
-    if VERSION_CLASS is None:
-        return version
-    version = pkg_parse_version(version)
-    trace("version", repr(version))
-    if isinstance(version, VERSION_CLASS):
-        return version
 
+    if tagdict.get('suffix', ''):
+        warnings.warn("tag %r will be stripped of its suffix '%s'" % (tag, tagdict['suffix']))
+
+    if VERSION_CLASS is not None:
+        version = pkg_parse_version(version)
+        trace("version", repr(version))
+    
+    return version
 
-def tags_to_versions(tags):
-    versions = map(tag_to_version, tags)
-    return [v for v in versions if v is not None]
+
+def tags_to_versions(tags, config=None):
+    """
+    take tags that might be prefixed with a keyword and return only the version part
+    :param tags: an iterable of tags
+    :param config: optional configuration object
+    """
+    return filter(None, map(lambda tag: tag_to_version(tag, config=config), tags))
 
 
 class ScmVersion(object):
@@ -139,16 +168,18 @@ class ScmVersion(object):
         return self.format_with(fmt, guessed=guessed)
 
 
-def _parse_tag(tag, preformatted):
+def _parse_tag(tag, preformatted, config):
     if preformatted:
         return tag
     if VERSION_CLASS is None or not isinstance(tag, VERSION_CLASS):
-        tag = tag_to_version(tag)
+        tag = tag_to_version(tag, config)
     return tag
 
 
-def meta(tag, distance=None, dirty=False, node=None, preformatted=False, **kw):
-    parsed_version = _parse_tag(tag, preformatted)
+def meta(tag, distance=None, dirty=False, node=None, preformatted=False, config=None, **kw):
+    if not config:
+        warnings.warn("meta invoked without explicit configuration, will use defaults where required.")
+    parsed_version = _parse_tag(tag, preformatted, config)
     trace("version", tag, "->", parsed_version)
     assert parsed_version is not None, "cant parse version %s" % tag
     return ScmVersion(parsed_version, distance, node, dirty, preformatted, **kw)


Test patch:
diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py
index b26688b..867d52d 100644
--- a/testing/test_basic_api.py
+++ b/testing/test_basic_api.py
@@ -35,8 +35,8 @@ def assert_root(monkeypatch, expected_root):
     Patch version_from_scm to simply assert that root is expected root
     """
 
-    def assertion(root, unused_parse):
-        assert root == expected_root
+    def assertion(config):
+        assert config.absolute_root == expected_root
 
     monkeypatch.setattr(setuptools_scm, "_do_parse", assertion)
 
diff --git a/testing/test_functions.py b/testing/test_functions.py
index db573ac..a25fe85 100644
--- a/testing/test_functions.py
+++ b/testing/test_functions.py
@@ -34,11 +34,11 @@ def test_next_tag(tag, expected):
 
 
 VERSIONS = {
-    "exact": meta("1.1", None, False),
-    "zerodistance": meta("1.1", 0, False),
-    "dirty": meta("1.1", None, True),
-    "distance": meta("1.1", 3, False),
-    "distancedirty": meta("1.1", 3, True),
+    "exact": meta("1.1", distance=None, dirty=False),
+    "zerodistance": meta("1.1", distance=0, dirty=False),
+    "dirty": meta("1.1", distance=None, dirty=True),
+    "distance": meta("1.1", distance=3, dirty=False),
+    "distancedirty": meta("1.1", distance=3, dirty=True),
 }
 
 
diff --git a/testing/test_version.py b/testing/test_version.py
index bbfe17a..4de545c 100644
--- a/testing/test_version.py
+++ b/testing/test_version.py
@@ -1,4 +1,5 @@
 import pytest
+from setuptools_scm.config import Configuration
 from setuptools_scm.version import meta, simplified_semver_version
 
 
@@ -37,3 +38,17 @@ from setuptools_scm.version import meta, simplified_semver_version
 def test_next_semver(version, expected_next):
     computed = simplified_semver_version(version)
     assert computed == expected_next
+
+
+@pytest.mark.parametrize(
+    "tag, expected",
+    [
+        pytest.param("v1.0.0", "1.0.0"),
+        pytest.param("v1.0.0-rc.1", "1.0.0rc1"),
+        pytest.param("v1.0.0-rc.1+-25259o4382757gjurh54", "1.0.0rc1"),
+    ],
+)
+def test_tag_regex1(tag, expected):
+    Configuration().tag_regex = r'^(?P<prefix>v)?(?P<version>[^\+]+)(?P<suffix>.*)?$'
+    result = meta(tag)
+    assert result.tag.public == expected