From 5b8bb16cbd124ae7bf79a11abd61743f5d564c71 Mon Sep 17 00:00:00 2001 From: Thomas Schraitle Date: Sun, 15 Mar 2020 18:54:48 +0100 Subject: [PATCH] Fix #225: Deprecate module level functions * Add test cases - Add additional test case for "check" - test_should_process_check_iscalled_with_valid_version - Test also missing finalize_version - Test the warning more thoroughly with pytest.warns instead of just pytest.deprecated_call * In `setup.cfg`, add deprecation warnings filter for pytest * Implement DeprecationWarning with warnings module and the new decorator `deprecated` * Output a DeprecationWarning for the following functions: - semver.bump_{major,minor,patch,prerelease,build} - semver.format_version - semver.finalize_version - semver.parse - semver.parse_version_info - semver.replace - semver.VersionInfo._asdict - semver.VersionInfo._astuple Add also a deprecation notice in the docstrings of these functions * Introduce new public functions: - semver.VersionInfo.to_dict (from former _asdict) - semver.VersionInfo.to_tuple (from former _astuple) - Keep _asdict and _astuple as a (deprecated) function for compatibility reasons * Update CHANGELOG.rst * Update usage documentation: - Move some information to make them more useful for for the reader - Add deprecation warning - Explain how to replace deprecated functions - Explain how to display deprecation warnings from semver * Improve documentation of deprecated functions - List deprecated module level functions - Make recommendation and show equivalent code - Mention that deprecated functions will be replaced in semver 3. That means, all deprecated function will be still available in semver 2.x.y. * Move _increment_string into VersionInfo class - Makes removing deprecating functions easier as, for example, bump_prerelease is no longer dependant from an "external" function. - Move _LAST_NUMBER regex into VersionInfo class - Implement _increment_string as a staticmethod Co-authored-by: Karol Co-authored-by: scls19fr Co-authored-by: George Sakkis --- .gitignore | 8 +- CHANGELOG.rst | 16 ++ docs/usage.rst | 254 ++++++++++++++++++++++++------ semver.py | 408 ++++++++++++++++++++++++++++++++----------------- setup.cfg | 2 + test_semver.py | 58 ++++++- 6 files changed, 556 insertions(+), 190 deletions(-) diff --git a/.gitignore b/.gitignore index 994eb868..2ef76af8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ -# Files +# Patch/Diff Files *.patch *.diff -*.kate-swp # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] .pytest_cache/ +*$py.class # Distribution / packaging .cache @@ -72,3 +72,7 @@ docs/_build/ # PyBuilder target/ + +# Backup files +*~ +*.kate-swp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2ae377d9..48e3d82b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -27,8 +27,24 @@ Additions * :pr:`228`: Added better doctest integration + Removals -------- +* :gh:`225` (:pr:`229`): Output a DeprecationWarning for the following functions: + + - ``semver.parse`` + - ``semver.parse_version_info`` + - ``semver.format_version`` + - ``semver.bump_{major,minor,patch,prerelease,build}`` + - ``semver.finalize_version`` + - ``semver.replace`` + - ``semver.VersionInfo._asdict`` (use the new, public available + function ``semver.VersionInfo.to_dict()``) + - ``semver.VersionInfo._astuple`` (use the new, public available + function ``semver.VersionInfo.to_tuple()``) + + These deprecated functions will be removed in semver 3. + Version 2.9.1 diff --git a/docs/usage.rst b/docs/usage.rst index dd05fa1c..cdf08b9a 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -14,9 +14,10 @@ are met. Knowing the Implemented semver.org Version ------------------------------------------ -The semver.org is the authorative specification of how semantical versioning is -definied. To know which version of semver.org is implemented in the semver -libary, use the following constant:: +The semver.org page is the authorative specification of how semantical +versioning is definied. +To know which version of semver.org is implemented in the semver libary, +use the following constant:: >>> semver.SEMVER_SPEC_VERSION '2.0.0' @@ -25,35 +26,81 @@ libary, use the following constant:: Creating a Version ------------------ -A version can be created in different ways: +Due to historical reasons, the semver project offers two ways of +creating a version: -* as a complete version string:: +* through an object oriented approach with the :class:`semver.VersionInfo` + class. This is the preferred method when using semver. + +* through module level functions and builtin datatypes (usually strings + and dicts). + These method are still available for compatibility reasons, but are + marked as deprecated. Using one of these will emit a DeprecationWarning. + + +.. warning:: **Deprecation Warning** + + Module level functions are marked as *deprecated* in version 2.9.2 now. + These functions will be removed in semver 3. + For details, see the sections :ref:`sec_replace_deprecated_functions` and + :ref:`sec_display_deprecation_warnings`. + + +A :class:`semver.VersionInfo` instance can be created in different ways: + + +* From a string:: - >>> semver.parse_version_info("3.4.5-pre.2+build.4") - VersionInfo(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') >>> semver.VersionInfo.parse("3.4.5-pre.2+build.4") VersionInfo(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') -* with individual parts:: +* From individual parts by a dictionary:: - >>> semver.format_version(3, 4, 5, 'pre.2', 'build.4') - '3.4.5-pre.2+build.4' - >>> semver.VersionInfo(3, 5) - VersionInfo(major=3, minor=5, patch=0, prerelease=None, build=None) + >>> d = {'major': 3, 'minor': 4, 'patch': 5, 'prerelease': 'pre.2', 'build': 'build.4'} + >>> semver.VersionInfo(**d) + VersionInfo(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') + + 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. + Only the keys ``major``, ``minor``, ``patch``, ``prerelease``, and ``build`` + are allowed. + +* From a tuple:: + + >>> t = (3, 5, 6) + >>> semver.VersionInfo(*t) + VersionInfo(major=3, minor=5, patch=6, prerelease=None, build=None) You can pass either an integer or a string for ``major``, ``minor``, or ``patch``:: - >>> semver.VersionInfo("3", "5") - VersionInfo(major=3, minor=5, patch=0, prerelease=None, build=None) + >>> semver.VersionInfo("3", "5", 6) + VersionInfo(major=3, minor=5, patch=6, prerelease=None, build=None) + +The old, deprecated module level functions are still available. If you +need them, they return different builtin objects (string and dictionary). +Keep in mind, once you have converted a version into a string or dictionary, +it's an ordinary builtin object. It's not a special version object like +the :class:`semver.VersionInfo` class anymore. + +Depending on your use case, the following methods are available: + +* From individual version parts into a string + + In some cases you only need a string from your version data:: + + >>> semver.format_version(3, 4, 5, 'pre.2', 'build.4') + '3.4.5-pre.2+build.4' + +* From a string into a dictionary - In the simplest form, ``prerelease`` and ``build`` can also be - integers:: + To access individual parts, you can use the function :func:`semver.parse`:: - >>> semver.VersionInfo(1, 2, 3, 4, 5) - VersionInfo(major=1, minor=2, patch=3, prerelease='4', build='5') + >>> semver.parse("3.4.5-pre.2+build.4") + OrderedDict([('major', 3), ('minor', 4), ('patch', 5), ('prerelease', 'pre.2'), ('build', 'build.4')]) -If you pass an invalid version string you will get a ``ValueError``:: + If you pass an invalid version string you will get a ``ValueError``:: >>> semver.parse("1.2") Traceback (most recent call last): @@ -172,45 +219,30 @@ If you pass invalid keys you get an exception:: .. _sec.convert.versions: -Converting Different Version Types ----------------------------------- +Converting a VersionInfo instance into Different Types +------------------------------------------------------ -Depending which function you call, you get different types -(as explained in the beginning of this chapter). +Sometimes it is needed to convert a :class:`semver.VersionInfo` instance into +a different type. For example, for displaying or to access all parts. -* From a string into :class:`semver.VersionInfo`:: - - >>> semver.VersionInfo.parse("3.4.5-pre.2+build.4") - VersionInfo(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') +It is possible to convert a :class:`semver.VersionInfo` instance: -* From :class:`semver.VersionInfo` into a string:: +* Into a string with the builtin function :func:`str`:: >>> str(semver.VersionInfo.parse("3.4.5-pre.2+build.4")) '3.4.5-pre.2+build.4' -* From a dictionary into :class:`semver.VersionInfo`:: +* Into a dictionary with :func:`semver.VersionInfo.to_dict`:: - >>> d = {'major': 3, 'minor': 4, 'patch': 5, 'prerelease': 'pre.2', 'build': 'build.4'} - >>> semver.VersionInfo(**d) - VersionInfo(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') - - 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. - Only ``major``, ``minor``, ``patch``, ``prerelease``, and ``build`` - are allowed. - -* From a tuple into :class:`semver.VersionInfo`:: - - >>> t = (3, 5, 6) - >>> semver.VersionInfo(*t) - VersionInfo(major=3, minor=5, patch=6, prerelease=None, build=None) + >>> v = semver.VersionInfo(major=3, minor=4, patch=5) + >>> v.to_dict() + OrderedDict([('major', 3), ('minor', 4), ('patch', 5), ('prerelease', None), ('build', None)]) -* From a :class:`semver.VersionInfo` into a dictionary:: +* Into a tuple with :func:`semver.VersionInfo.to_tuple`:: - >>> v = semver.VersionInfo(major=3, minor=4, patch=5) - >>> semver.parse(str(v)) == {'major': 3, 'minor': 4, 'patch': 5, 'prerelease': None, 'build': None} - True + >>> v = semver.VersionInfo(major=5, minor=4, patch=2) + >>> v.to_tuple() + (5, 4, 2, None, None) Increasing Parts of a Version @@ -362,8 +394,132 @@ For example: .. code-block:: python - >>> coerce("v1.2") + >>> coerce("v1.2") (VersionInfo(major=1, minor=2, patch=0, prerelease=None, build=None), '') >>> coerce("v2.5.2-bla") (VersionInfo(major=2, minor=5, patch=2, prerelease=None, build=None), '-bla') + +.. _sec_replace_deprecated_functions: + +Replacing Deprecated Functions +------------------------------ + +The development team of semver has decided to deprecate certain functions on +the module level. The preferred way of using semver is through the +:class:`semver.VersionInfo` class. + +The deprecated functions can still be used in version 2.x.y. In version 3 of +semver, the deprecated functions will be removed. + +The following list shows the deprecated functions and how you can replace +them with code which is compatible for future versions: + + +* :func:`semver.bump_major`, :func:`semver.bump_minor`, :func:`semver.bump_patch`, :func:`semver.bump_prerelease`, :func:`semver.bump_build` + + Replace them with the respective methods of the :class:`semver.VersionInfo` + class. + For example, the function :func:`semver.bump_major` is replaced by + :func:`semver.VersionInfo.bump_major` and calling the ``str(versionobject)``: + + .. code-block:: python + + >>> s1 = semver.bump_major("3.4.5") + >>> s2 = str(semver.VersionInfo.parse("3.4.5").bump_major()) + >>> s1 == s2 + True + + Likewise with the other module level functions. + +* :func:`semver.finalize_version` + + Replace it with :func:`semver.VersionInfo.finalize_version`: + + .. code-block:: python + + >>> s1 = semver.finalize_version('1.2.3-rc.5') + >>> s2 = str(semver.VersionInfo.parse('1.2.3-rc.5').finalize_version()) + >>> s1 == s2 + True + +* :func:`semver.format_version` + + Replace it with ``str(versionobject)``: + + .. code-block:: python + + >>> s1 = semver.format_version(5, 4, 3, 'pre.2', 'build.1') + >>> s2 = str(semver.VersionInfo(5, 4, 3, 'pre.2', 'build.1')) + >>> s1 == s2 + True + +* :func:`semver.parse` + + Replace it with :func:`semver.VersionInfo.parse` and + :func:`semver.VersionInfo.to_dict`: + + .. code-block:: python + + >>> v1 = semver.parse("1.2.3") + >>> v2 = semver.VersionInfo.parse("1.2.3").to_dict() + >>> v1 == v2 + True + +* :func:`semver.parse_version_info` + + Replace it with :func:`semver.VersionInfo.parse`: + + .. code-block:: python + + >>> v1 = semver.parse_version_info("3.4.5") + >>> v2 = semver.VersionInfo.parse("3.4.5") + >>> v1 == v2 + True + +* :func:`semver.replace` + + Replace it with :func:`semver.VersionInfo.replace`: + + .. code-block:: python + + >>> s1 = semver.replace("1.2.3", major=2, patch=10) + >>> s2 = str(semver.VersionInfo.parse('1.2.3').replace(major=2, patch=10)) + >>> s1 == s2 + True + + +.. _sec_display_deprecation_warnings: + +Displaying Deprecation Warnings +------------------------------- + +By default, deprecation warnings are `ignored in Python `_. +This also affects semver's own warnings. + +It is recommended that you turn on deprecation warnings in your scripts. Use one of +the following methods: + +* Use the option `-Wd `_ + to enable default warnings: + + * Directly running the Python command:: + + $ python3 -Wd scriptname.py + + * Add the option in the shebang line (something like ``#!/usr/bin/python3``) + after the command:: + + #!/usr/bin/python3 -Wd + +* In your own scripts add a filter to ensure that *all* warnings are displayed: + + .. code-block:: python + + import warnings + warnings.simplefilter("default") + # Call your semver code + + For further details, see the section + `Overriding the default filter `_ + of the Python documentation. diff --git a/semver.py b/semver.py index aca7242e..aec5e9ef 100644 --- a/semver.py +++ b/semver.py @@ -3,9 +3,11 @@ import argparse import collections -from functools import wraps +from functools import wraps, partial +import inspect import re import sys +import warnings __version__ = "2.9.1" @@ -14,28 +16,6 @@ __maintainer__ = ["Sebastien Celles", "Tom Schraitle"] __maintainer_email__ = "s.celles@gmail.com" -_REGEX = re.compile( - r""" - ^ - (?P0|[1-9]\d*) - \. - (?P0|[1-9]\d*) - \. - (?P0|[1-9]\d*) - (?:-(?P - (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) - (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* - ))? - (?:\+(?P - [0-9a-zA-Z-]+ - (?:\.[0-9a-zA-Z-]+)* - ))? - $ - """, - re.VERBOSE, -) - -_LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+") #: Contains the implemented semver.org version of the spec SEMVER_SPEC_VERSION = "2.0.0" @@ -47,10 +27,66 @@ def cmp(a, b): return (a > b) - (a < b) +def deprecated(func=None, replace=None, version=None, category=DeprecationWarning): + """ + Decorates a function to output a deprecation warning. + + This function will be removed once major version 3 of semver is + released. + + :param str replace: the function to replace (use the full qualified + name like ``semver.VersionInfo.bump_major``. + :param str version: the first version when this function was deprecated. + :param category: allow you to specify the deprecation warning class + of your choice. By default, it's :class:`DeprecationWarning`, but + you can choose :class:`PendingDeprecationWarning``or a custom class. + """ + + if func is None: + return partial(deprecated, replace=replace, version=version, category=category) + + @wraps(func) + def wrapper(*args, **kwargs): + msg = ["Function '{m}.{f}' is deprecated."] + + if version: + msg.append("Deprecated since version {v}. ") + msg.append("This function will be removed in semver 3.") + if replace: + msg.append("Use {r!r} instead.") + else: + msg.append("Use the respective 'semver.VersionInfo.{r}' instead.") + + # hasattr is needed for Python2 compatibility: + f = func.__qualname__ if hasattr(func, "__qualname__") else func.__name__ + r = replace or f + + frame = inspect.currentframe().f_back + + msg = " ".join(msg) + warnings.warn_explicit( + msg.format(m=func.__module__, f=f, r=r, v=version), + category=category, + filename=inspect.getfile(frame.f_code), + lineno=frame.f_lineno, + ) + # As recommended in the Python documentation + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + # better remove the interpreter stack: + del frame + return func(*args, **kwargs) + + return wrapper + + +@deprecated(version="2.9.2") def parse(version): """ Parse version to major, minor, patch, pre-release, build parts. + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.parse` instead. + :param version: version string :return: dictionary with the keys 'build', 'major', 'minor', 'patch', and 'prerelease'. The prerelease or build keys can be None @@ -69,17 +105,7 @@ def parse(version): >>> ver['build'] 'build.4' """ - match = _REGEX.match(version) - if match is None: - raise ValueError("%s is not valid SemVer string" % version) - - version_parts = match.groupdict() - - version_parts["major"] = int(version_parts["major"]) - version_parts["minor"] = int(version_parts["minor"]) - version_parts["patch"] = int(version_parts["patch"]) - - return version_parts + return VersionInfo.parse(version).to_dict() def comparator(operator): @@ -110,6 +136,29 @@ class VersionInfo(object): """ __slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build") + #: Regex for number in a prerelease + _LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+") + #: Regex for a semver version + _REGEX = re.compile( + r""" + ^ + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + \. + (?P0|[1-9]\d*) + (?:-(?P + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* + ))? + (?:\+(?P + [0-9a-zA-Z-]+ + (?:\.[0-9a-zA-Z-]+)* + ))? + $ + """, + re.VERBOSE, + ) def __init__(self, major, minor=0, patch=0, prerelease=None, build=None): self._major = int(major) @@ -163,10 +212,38 @@ def build(self): def build(self, value): raise AttributeError("attribute 'build' is readonly") - def _astuple(self): + def to_tuple(self): + """ + Convert the VersionInfo object to a tuple. + + .. versionadded:: 2.9.2 + Renamed ``VersionInfo._astuple`` to ``VersionInfo.to_tuple`` to + make this function available in the public API. + + :return: a tuple with all the parts + :rtype: tuple + + >>> semver.VersionInfo(5, 3, 1).to_tuple() + (5, 3, 1, None, None) + """ return (self.major, self.minor, self.patch, self.prerelease, self.build) - def _asdict(self): + def to_dict(self): + """ + Convert the VersionInfo object to an OrderedDict. + + .. versionadded:: 2.9.2 + Renamed ``VersionInfo._asdict`` to ``VersionInfo.to_dict`` to + make this function available in the public API. + + :return: an OrderedDict with the keys in the order ``major``, ``minor``, + ``patch``, ``prerelease``, and ``build``. + :rtype: :class:`collections.OrderedDict` + + >>> semver.VersionInfo(3, 2, 1).to_dict() + OrderedDict([('major', 3), ('minor', 2), ('patch', 1), \ +('prerelease', None), ('build', None)]) + """ return collections.OrderedDict( ( ("major", self.major), @@ -177,12 +254,43 @@ def _asdict(self): ) ) + # For compatibility reasons: + @deprecated(replace="semver.VersionInfo.to_tuple", version="2.9.2") + def _astuple(self): + return self.to_tuple() # pragma: no cover + + _astuple.__doc__ = to_tuple.__doc__ + + @deprecated(replace="semver.VersionInfo.to_dict", version="2.9.2") + def _asdict(self): + return self.to_dict() # pragma: no cover + + _asdict.__doc__ = to_dict.__doc__ + def __iter__(self): """Implement iter(self).""" # As long as we support Py2.7, we can't use the "yield from" syntax - for v in self._astuple(): + for v in self.to_tuple(): yield v + @staticmethod + def _increment_string(string): + """ + Look for the last sequence of number(s) in a string and increment. + + :param str string: the string to search for. + :return: the incremented string + + Source: + http://code.activestate.com/recipes/442460-increment-numbers-in-a-string/#c1 + """ + match = VersionInfo._LAST_NUMBER.search(string) + if match: + next_ = str(int(match.group(1)) + 1) + start, end = match.span(1) + string = string[: max(end - len(next_), start)] + next_ + string[end:] + return string + def bump_major(self): """ Raise the major part of the version, return a new object but leave self @@ -195,7 +303,8 @@ def bump_major(self): >>> ver.bump_major() VersionInfo(major=4, minor=0, patch=0, prerelease=None, build=None) """ - return parse_version_info(bump_major(str(self))) + cls = type(self) + return cls(self._major + 1) def bump_minor(self): """ @@ -209,7 +318,8 @@ def bump_minor(self): >>> ver.bump_minor() VersionInfo(major=3, minor=5, patch=0, prerelease=None, build=None) """ - return parse_version_info(bump_minor(str(self))) + cls = type(self) + return cls(self._major, self._minor + 1) def bump_patch(self): """ @@ -223,7 +333,8 @@ def bump_patch(self): >>> ver.bump_patch() VersionInfo(major=3, minor=4, patch=6, prerelease=None, build=None) """ - return parse_version_info(bump_patch(str(self))) + cls = type(self) + return cls(self._major, self._minor, self._patch + 1) def bump_prerelease(self, token="rc"): """ @@ -239,7 +350,9 @@ def bump_prerelease(self, token="rc"): VersionInfo(major=3, minor=4, patch=5, prerelease='rc.2', \ build=None) """ - return parse_version_info(bump_prerelease(str(self), token)) + cls = type(self) + prerelease = cls._increment_string(self._prerelease or (token or "rc") + ".0") + return cls(self._major, self._minor, self._patch, prerelease) def bump_build(self, token="build"): """ @@ -255,41 +368,62 @@ def bump_build(self, token="build"): VersionInfo(major=3, minor=4, patch=5, prerelease='rc.1', \ build='build.10') """ - return parse_version_info(bump_build(str(self), token)) + cls = type(self) + build = cls._increment_string(self._build or (token or "build") + ".0") + return cls(self._major, self._minor, self._patch, self._prerelease, build) @comparator def __eq__(self, other): - return _compare_by_keys(self._asdict(), _to_dict(other)) == 0 + return _compare_by_keys(self.to_dict(), _to_dict(other)) == 0 @comparator def __ne__(self, other): - return _compare_by_keys(self._asdict(), _to_dict(other)) != 0 + return _compare_by_keys(self.to_dict(), _to_dict(other)) != 0 @comparator def __lt__(self, other): - return _compare_by_keys(self._asdict(), _to_dict(other)) < 0 + return _compare_by_keys(self.to_dict(), _to_dict(other)) < 0 @comparator def __le__(self, other): - return _compare_by_keys(self._asdict(), _to_dict(other)) <= 0 + return _compare_by_keys(self.to_dict(), _to_dict(other)) <= 0 @comparator def __gt__(self, other): - return _compare_by_keys(self._asdict(), _to_dict(other)) > 0 + return _compare_by_keys(self.to_dict(), _to_dict(other)) > 0 @comparator def __ge__(self, other): - return _compare_by_keys(self._asdict(), _to_dict(other)) >= 0 + return _compare_by_keys(self.to_dict(), _to_dict(other)) >= 0 def __repr__(self): - s = ", ".join("%s=%r" % (key, val) for key, val in self._asdict().items()) + s = ", ".join("%s=%r" % (key, val) for key, val in self.to_dict().items()) return "%s(%s)" % (type(self).__name__, s) def __str__(self): - return format_version(*(self._astuple())) + """str(self)""" + version = "%d.%d.%d" % (self.major, self.minor, self.patch) + if self.prerelease: + version += "-%s" % self.prerelease + if self.build: + version += "+%s" % self.build + return version def __hash__(self): - return hash(self._astuple()) + return hash(self.to_tuple()) + + def finalize_version(self): + """ + Remove any prerelease and build metadata from the version. + + :return: a new instance with the finalized version string + :rtype: :class:`VersionInfo` + + >>> str(semver.VersionInfo.parse('1.2.3-rc.5').finalize_version()) + '1.2.3' + """ + cls = type(self) + return cls(self.major, self.minor, self.patch) @staticmethod def parse(version): @@ -304,7 +438,17 @@ def parse(version): VersionInfo(major=3, minor=4, patch=5, \ prerelease='pre.2', build='build.4') """ - return parse_version_info(version) + match = VersionInfo._REGEX.match(version) + if match is None: + raise ValueError("%s is not valid SemVer string" % version) + + version_parts = match.groupdict() + + version_parts["major"] = int(version_parts["major"]) + version_parts["minor"] = int(version_parts["minor"]) + version_parts["patch"] = int(version_parts["patch"]) + + return VersionInfo(**version_parts) def replace(self, **parts): """ @@ -320,12 +464,12 @@ def replace(self, **parts): parts :raises: TypeError, if ``parts`` contains invalid keys """ - version = self._asdict() + version = self.to_dict() version.update(parts) try: return VersionInfo(**version) except TypeError: - unknownkeys = set(parts) - set(self._asdict()) + unknownkeys = set(parts) - set(self.to_dict()) error = "replace() got %d unexpected keyword " "argument(s): %s" % ( len(unknownkeys), ", ".join(unknownkeys), @@ -353,16 +497,23 @@ def isvalid(cls, version): def _to_dict(obj): if isinstance(obj, VersionInfo): - return obj._asdict() + return obj.to_dict() elif isinstance(obj, tuple): - return VersionInfo(*obj)._asdict() + return VersionInfo(*obj).to_dict() return obj +@deprecated(replace="semver.VersionInfo.parse", version="2.9.2") def parse_version_info(version): """ Parse version string to a VersionInfo instance. + .. versionadded:: 2.7.2 + Added :func:`parse_version_info` + + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.parse` instead. + .. versionadded:: 2.7.2 Added :func:`parse_version_info` @@ -382,16 +533,7 @@ def parse_version_info(version): >>> version_info.build 'build.4' """ - parts = parse(version) - version_info = VersionInfo( - parts["major"], - parts["minor"], - parts["patch"], - parts["prerelease"], - parts["build"], - ) - - return version_info + return VersionInfo.parse(version) def _nat_cmp(a, b): @@ -458,7 +600,8 @@ def compare(ver1, ver2): 0 """ - v1, v2 = parse(ver1), parse(ver2) + v1 = VersionInfo.parse(ver1).to_dict() + v2 = VersionInfo.parse(ver2).to_dict() return _compare_by_keys(v1, v2) @@ -550,10 +693,14 @@ def min_ver(ver1, ver2): return ver2 +@deprecated(replace="str(versionobject)", version="2.9.2") def format_version(major, minor, patch, prerelease=None, build=None): """ Format a version string according to the Semantic Versioning specification. + .. deprecated:: 2.9.2 + Use ``str(VersionInfo(VERSION)`` instead. + :param int major: the required major part of a version :param int minor: the required minor part of a version :param int patch: the required patch part of a version @@ -565,34 +712,17 @@ def format_version(major, minor, patch, prerelease=None, build=None): >>> semver.format_version(3, 4, 5, 'pre.2', 'build.4') '3.4.5-pre.2+build.4' """ - version = "%d.%d.%d" % (major, minor, patch) - if prerelease is not None: - version = version + "-%s" % prerelease - - if build is not None: - version = version + "+%s" % build - - return version - - -def _increment_string(string): - """ - Look for the last sequence of number(s) in a string and increment, from: - - http://code.activestate.com/recipes/442460-increment-numbers-in-a-string/#c1 - """ - match = _LAST_NUMBER.search(string) - if match: - next_ = str(int(match.group(1)) + 1) - start, end = match.span(1) - string = string[: max(end - len(next_), start)] + next_ + string[end:] - return string + return str(VersionInfo(major, minor, patch, prerelease, build)) +@deprecated(version="2.9.2") def bump_major(version): """ Raise the major part of the version string. + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.bump_major` instead. + :param: version string :return: the raised version string :rtype: str @@ -600,14 +730,17 @@ def bump_major(version): >>> semver.bump_major("3.4.5") '4.0.0' """ - verinfo = parse(version) - return format_version(verinfo["major"] + 1, 0, 0) + return str(VersionInfo.parse(version).bump_major()) +@deprecated(version="2.9.2") def bump_minor(version): """ Raise the minor part of the version string. + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.bump_minor` instead. + :param: version string :return: the raised version string :rtype: str @@ -615,14 +748,17 @@ def bump_minor(version): >>> semver.bump_minor("3.4.5") '3.5.0' """ - verinfo = parse(version) - return format_version(verinfo["major"], verinfo["minor"] + 1, 0) + return str(VersionInfo.parse(version).bump_minor()) +@deprecated(version="2.9.2") def bump_patch(version): """ Raise the patch part of the version string. + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.bump_patch` instead. + :param: version string :return: the raised version string :rtype: str @@ -630,14 +766,17 @@ def bump_patch(version): >>> semver.bump_patch("3.4.5") '3.4.6' """ - verinfo = parse(version) - return format_version(verinfo["major"], verinfo["minor"], verinfo["patch"] + 1) + return str(VersionInfo.parse(version).bump_patch()) +@deprecated(version="2.9.2") def bump_prerelease(version, token="rc"): """ Raise the prerelease part of the version string. + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.bump_prerelease` instead. + :param version: version string :param token: defaults to 'rc' :return: the raised version string @@ -646,19 +785,17 @@ def bump_prerelease(version, token="rc"): >>> semver.bump_prerelease('3.4.5', 'dev') '3.4.5-dev.1' """ - verinfo = parse(version) - verinfo["prerelease"] = _increment_string( - verinfo["prerelease"] or (token or "rc") + ".0" - ) - return format_version( - verinfo["major"], verinfo["minor"], verinfo["patch"], verinfo["prerelease"] - ) + return str(VersionInfo.parse(version).bump_prerelease(token)) +@deprecated(version="2.9.2") def bump_build(version, token="build"): """ Raise the build part of the version string. + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.bump_build` instead. + :param version: version string :param token: defaults to 'build' :return: the raised version string @@ -667,17 +804,10 @@ def bump_build(version, token="build"): >>> semver.bump_build('3.4.5-rc.1+build.9') '3.4.5-rc.1+build.10' """ - verinfo = parse(version) - verinfo["build"] = _increment_string(verinfo["build"] or (token or "build") + ".0") - return format_version( - verinfo["major"], - verinfo["minor"], - verinfo["patch"], - verinfo["prerelease"], - verinfo["build"], - ) + return str(VersionInfo.parse(version).bump_build(token)) +@deprecated(version="2.9.2") def finalize_version(version): """ Remove any prerelease and build metadata from the version string. @@ -685,6 +815,9 @@ def finalize_version(version): .. versionadded:: 2.7.9 Added :func:`finalize_version` + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.finalize_version` instead. + :param version: version string :return: the finalized version string :rtype: str @@ -692,8 +825,33 @@ def finalize_version(version): >>> semver.finalize_version('1.2.3-rc.5') '1.2.3' """ - verinfo = parse(version) - return format_version(verinfo["major"], verinfo["minor"], verinfo["patch"]) + verinfo = VersionInfo.parse(version) + return str(verinfo.finalize_version()) + + +@deprecated(version="2.9.2") +def replace(version, **parts): + """ + Replace one or more parts of a version and return the new string. + + .. versionadded:: 2.9.0 + Added :func:`replace` + + .. deprecated:: 2.9.2 + Use :func:`semver.VersionInfo.replace` instead. + + :param str version: the version string to replace + :param dict parts: the parts to be updated. Valid keys are: + ``major``, ``minor``, ``patch``, ``prerelease``, or ``build`` + :return: the replaced version string + :raises: TypeError, if ``parts`` contains invalid keys + :rtype: str + + >>> import semver + >>> semver.replace("1.2.3", major=2, patch=10) + '2.2.10' + """ + return str(VersionInfo.parse(version).replace(**parts)) def cmd_bump(args): @@ -719,7 +877,7 @@ def cmd_bump(args): # print the help and exit args.parser.parse_args(["bump", "-h"]) - ver = parse_version_info(args.version) + ver = VersionInfo.parse(args.version) # get the respective method and call it func = getattr(ver, maptable[args.bump]) return str(func()) @@ -838,28 +996,6 @@ def main(cliargs=None): return 2 -def replace(version, **parts): - """ - Replace one or more parts of a version and return the new string. - - .. versionadded:: 2.9.0 - Added :func:`replace` - - :param str version: the version string to replace - :param dict parts: the parts to be updated. Valid keys are: - ``major``, ``minor``, ``patch``, ``prerelease``, or ``build`` - :return: the replaced version string - :raises: TypeError, if ``parts`` contains invalid keys - :rtype: str - - >>> import semver - >>> semver.replace("1.2.3", major=2, patch=10) - '2.2.10' - """ - version = parse_version_info(version) - return str(version.replace(**parts)) - - if __name__ == "__main__": import doctest diff --git a/setup.cfg b/setup.cfg index 6ab7e562..1cefc4bf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,8 @@ [tool:pytest] norecursedirs = .git build .env/ env/ .pyenv/ .tmp/ .eggs/ testpaths = . docs +filterwarnings = + ignore:Function 'semver.*:DeprecationWarning addopts = --no-cov-on-fail --cov=semver diff --git a/test_semver.py b/test_semver.py index ed7d80bc..4611d771 100644 --- a/test_semver.py +++ b/test_semver.py @@ -14,6 +14,7 @@ cmd_compare, compare, createparser, + deprecated, finalize_version, format_version, main, @@ -54,9 +55,7 @@ def does_not_raise(item): "string,expected", [("rc", "rc"), ("rc.1", "rc.2"), ("2x", "3x")] ) def test_should_private_increment_string(string, expected): - from semver import _increment_string - - assert _increment_string(string) == expected + assert VersionInfo._increment_string(string) == expected @pytest.fixture @@ -393,6 +392,18 @@ def test_should_versioninfo_bump_multiple(): assert v.bump_prerelease().bump_build().bump_build().bump_prerelease() == expected +def test_should_versioninfo_to_dict(version): + resultdict = version.to_dict() + assert isinstance(resultdict, dict), "Got type from to_dict" + assert list(resultdict.keys()) == ["major", "minor", "patch", "prerelease", "build"] + + +def test_should_versioninfo_to_tuple(version): + result = version.to_tuple() + assert isinstance(result, tuple), "Got type from to_dict" + assert len(result) == 5, "Different length from to_tuple()" + + def test_should_ignore_extensions_for_bump(): assert bump_patch("3.4.5-rc1+build4") == "3.4.6" @@ -777,6 +788,13 @@ def test_should_raise_systemexit_when_bump_iscalled_with_empty_arguments(): main(["bump"]) +def test_should_process_check_iscalled_with_valid_version(capsys): + result = main(["check", "1.1.1"]) + assert not result + captured = capsys.readouterr() + assert not captured.out + + @pytest.mark.parametrize( "version,parts,expected", [ @@ -827,3 +845,37 @@ def test_replace_raises_ValueError_for_non_numeric_values(): def test_should_versioninfo_isvalid(): assert VersionInfo.isvalid("1.0.0") is True assert VersionInfo.isvalid("foo") is False + + +@pytest.mark.parametrize( + "func, args, kwargs", + [ + (bump_build, ("1.2.3",), {}), + (bump_major, ("1.2.3",), {}), + (bump_minor, ("1.2.3",), {}), + (bump_patch, ("1.2.3",), {}), + (bump_prerelease, ("1.2.3",), {}), + (format_version, (3, 4, 5), {}), + (finalize_version, ("1.2.3-rc.5",), {}), + (parse, ("1.2.3",), {}), + (parse_version_info, ("1.2.3",), {}), + (replace, ("1.2.3",), dict(major=2, patch=10)), + ], +) +def test_should_raise_deprecation_warnings(func, args, kwargs): + with pytest.warns( + DeprecationWarning, match=r"Function 'semver.[_a-zA-Z]+' is deprecated." + ) as record: + func(*args, **kwargs) + if not record: + pytest.fail("Expected a DeprecationWarning for {}".format(func.__name__)) + assert len(record), "Expected one DeprecationWarning record" + + +def test_deprecated_deco_without_argument(): + @deprecated + def mock_func(): + return True + + with pytest.deprecated_call(): + assert mock_func()