From d9b18a870790818b1322b09ce501010ad5901abe Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Fri, 6 Nov 2020 21:45:43 +0100 Subject: [PATCH 1/8] Update documentation --- docs/migration/migratetosemver3.rst | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/migration/migratetosemver3.rst b/docs/migration/migratetosemver3.rst index 852ea68b..1ed324ef 100644 --- a/docs/migration/migratetosemver3.rst +++ b/docs/migration/migratetosemver3.rst @@ -1,5 +1,6 @@ .. _semver2-to-3: + Migrating from semver2 to semver3 ================================= @@ -15,8 +16,8 @@ For a more detailed overview of all the changes, refer to our :ref:`change-log`. -Use Version instead of VersionInfo ----------------------------------- +Use :class:`Version` instead of :class:`VersionInfo` +---------------------------------------------------- The :class:`VersionInfo` has been renamed to :class:`Version` to have a more succinct name. @@ -30,9 +31,20 @@ If you still need the old version, use this line: from semver.version import Version as VersionInfo +Use :class:`Version` instead of :meth:`Version.parse` +----------------------------------------------------- + +The :class:`~semver.version.Version` class supports now different variants +how a version can be called (see section :ref:`sec_creating_version` +for more details). + +It's important to know that you do not need to use +:meth:`Version.parse ` anymore. You +can pass a string directly to :class:`~semver.Version`. + -Use semver.cli instead of semver --------------------------------- +Use :mod:`semver.cli` instead of :mod:`semver` +---------------------------------------------- All functions related to CLI parsing are moved to :mod:`semver.cli`. If you need such functions, like :func:`semver.cmd_bump `, From d234050d9fd130f5a41bcdb1269ff98b8cb33946 Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Fri, 6 Nov 2020 16:24:15 +0100 Subject: [PATCH 2/8] Fix #303: Fix Version.__init__ method * Allow different variants to call Version * Adapt the documentation and README * Adapt and amend tests * Add changelog entries * Add function "remove_noqa" in conf.py to remove any "# noqa" lines for flake8 issues like overly long lines * Introduce a (private) _ensure_str class method --- README.rst | 4 +- changelog.d/303.doc.rst | 2 + changelog.d/303.feature.rst | 3 + docs/conf.py | 16 +++ docs/usage/create-a-version.rst | 38 ++++- src/semver/_types.py | 1 + src/semver/version.py | 242 ++++++++++++++++++++++---------- tests/test_semver.py | 81 ++++++++++- 8 files changed, 295 insertions(+), 92 deletions(-) create mode 100644 changelog.d/303.doc.rst create mode 100644 changelog.d/303.feature.rst diff --git a/README.rst b/README.rst index cad99a04..1fe466d9 100644 --- a/README.rst +++ b/README.rst @@ -51,7 +51,7 @@ different parts, use the ``semver.Version.parse`` function: .. code-block:: python - >>> ver = semver.Version.parse('1.2.3-pre.2+build.4') + >>> ver = semver.Version('1.2.3-pre.2+build.4') >>> ver.major 1 >>> ver.minor @@ -69,7 +69,7 @@ returns a new ``semver.Version`` instance with the raised major part: .. code-block:: python - >>> ver = semver.Version.parse("3.4.5") + >>> ver = semver.Version("3.4.5") >>> ver.bump_major() Version(major=4, minor=0, patch=0, prerelease=None, build=None) diff --git a/changelog.d/303.doc.rst b/changelog.d/303.doc.rst new file mode 100644 index 00000000..c70e02b1 --- /dev/null +++ b/changelog.d/303.doc.rst @@ -0,0 +1,2 @@ +Prefer :meth:`Version.__init__` over :meth:`Version.parse` +and change examples accordingly. \ No newline at end of file diff --git a/changelog.d/303.feature.rst b/changelog.d/303.feature.rst new file mode 100644 index 00000000..1ef2483c --- /dev/null +++ b/changelog.d/303.feature.rst @@ -0,0 +1,3 @@ +Extend :meth:`Version.__init__` initializer. It allows +now to have positional and keyword arguments. The keyword +arguments overwrites any positional arguments. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 9edfda4d..735e4332 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -267,3 +267,19 @@ def find_version(*file_paths): "Miscellaneous", ) ] + +# ---------------- +# Setup for Sphinx + + +def remove_noqa(app, what, name, obj, options, lines): + """Remove any 'noqa' parts in a docstring""" + noqa_pattern = re.compile(r"\s+# noqa:.*$") + # Remove any "# noqa" parts in a line + for idx, line in enumerate(lines): + lines[idx] = noqa_pattern.sub("", line, count=1) + + +def setup(app): + """Set up the Sphinx app.""" + app.connect("autodoc-process-docstring", remove_noqa) diff --git a/docs/usage/create-a-version.rst b/docs/usage/create-a-version.rst index 3acb4c03..84f11131 100644 --- a/docs/usage/create-a-version.rst +++ b/docs/usage/create-a-version.rst @@ -21,17 +21,21 @@ The preferred way to create a new version is with the class A :class:`~semver.version.Version` instance can be created in different ways: -* From a Unicode string:: + +* Without any arguments:: >>> from semver.version import Version - >>> Version.parse("3.4.5-pre.2+build.4") + >>> Version() + Version(major=0, minor=0, patch=0, prerelease=None, build=None) + +* From a Unicode string:: + + >>> Version("3.4.5-pre.2+build.4") Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') - >>> Version.parse(u"5.3.1") - Version(major=5, minor=3, patch=1, prerelease=None, build=None) * From a byte string:: - >>> Version.parse(b"2.3.4") + >>> Version(b"2.3.4") Version(major=2, minor=3, patch=4, prerelease=None, build=None) * From individual parts by a dictionary:: @@ -47,7 +51,7 @@ A :class:`~semver.version.Version` instance can be created in different ways: >>> Version(**d) Traceback (most recent call last): ... - ValueError: 'major' is negative. A version can only be positive. + ValueError: Argument -3 is negative. A version can only be positive. As a minimum requirement, your dictionary needs at least the ``major`` key, others can be omitted. You get a ``TypeError`` if your @@ -67,6 +71,28 @@ A :class:`~semver.version.Version` instance can be created in different ways: >>> Version("3", "5", 6) Version(major=3, minor=5, patch=6, prerelease=None, build=None) +It is possible to combine, positional and keyword arguments. In +some use cases you have a fixed version string, but would like to +replace parts of them. For example:: + + >>> Version(1, 2, 3, major=2, build="b2") + Version(major=2, minor=2, patch=3, prerelease=None, build='b2') + +It is also possible to use a version string and replace specific +parts:: + + >>> Version("1.2.3", major=2, build="b2") + Version(major=2, minor=2, patch=3, prerelease=None, build='b2') + +However, it is not possible to use a string and additional positional +arguments: + + >>> Version("1.2.3", 4) + Traceback (most recent call last): + ... + ValueError: You cannot pass a string and additional positional arguments + + The old, deprecated module level functions are still available but using them are discoraged. They are available to convert old code to semver3. diff --git a/src/semver/_types.py b/src/semver/_types.py index 7afb6ff0..0ad88597 100644 --- a/src/semver/_types.py +++ b/src/semver/_types.py @@ -8,5 +8,6 @@ VersionDict = Dict[str, VersionPart] VersionIterator = Iterable[VersionPart] String = Union[str, bytes] +StringOrInt = Union[String, int] F = TypeVar("F", bound=Callable) Decorator = Union[Callable[..., F], partial] diff --git a/src/semver/version.py b/src/semver/version.py index 9c135c5a..b0198143 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -5,25 +5,25 @@ from functools import wraps from typing import ( Any, + Callable, + Collection, Dict, Iterable, + List, Optional, SupportsInt, Tuple, Union, cast, - Callable, - Collection, - Type, - TypeVar, ) from ._types import ( - VersionTuple, + String, + StringOrInt, VersionDict, VersionIterator, - String, VersionPart, + VersionTuple, ) # These types are required here because of circular imports @@ -61,12 +61,28 @@ class Version: """ A semver compatible version class. + :param args: a tuple with version information. It can consist of: + + * a maximum length of 5 items that comprehend the major, + minor, patch, prerelease, or build parts. + * a str or bytes string that contains a valid semver + version string. :param major: version when you make incompatible API changes. :param minor: version when you add functionality in a backwards-compatible manner. :param patch: version when you make backwards-compatible bug fixes. :param prerelease: an optional prerelease string :param build: an optional build string + + This gives you some options to call the :class:`Version` class. + Precedence has the keyword arguments over the positional arguments. + + >>> Version(1, 2, 3) + Version(major=1, minor=2, patch=3, prerelease=None, build=None) + >>> Version("2.3.4-pre.2") + Version(major=2, minor=3, patch=4, prerelease="pre.2", build=None) + >>> Version(major=2, minor=3, patch=4, build="build.2") + Version(major=2, minor=3, patch=4, prerelease=None, build="build.2") """ __slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build") @@ -111,14 +127,71 @@ class Version: def __init__( self, - major: SupportsInt, + *args: Tuple[ + StringOrInt, # major + Optional[StringOrInt], # minor + Optional[StringOrInt], # patch + Optional[StringOrInt], # prerelease + Optional[StringOrInt], # build + ], + major: SupportsInt = 0, minor: SupportsInt = 0, patch: SupportsInt = 0, prerelease: Optional[Union[String, int]] = None, build: Optional[Union[String, int]] = None, ): + def _check_types(*args): + if args and len(args) > 5: + raise ValueError("You cannot pass more than 5 arguments to Version") + elif len(args) > 1 and "." in str(args[0]): + raise ValueError( + "You cannot pass a string and additional positional arguments" + ) + allowed_types_in_args = ( + (int, str, bytes), # major + (int, str, bytes), # minor + (int, str, bytes), # patch + (str, bytes, int, type(None)), # prerelease + (str, bytes, int, type(None)), # build + ) + return [ + isinstance(item, allowed_types_in_args[i]) + for i, item in enumerate(args) + ] + + cls = self.__class__ + verlist: List[Optional[StringOrInt]] = [None, None, None, None, None] + + types_in_args = _check_types(*args) + if not all(types_in_args): + pos = types_in_args.index(False) + raise TypeError( + "not expecting type in argument position " + f"{pos} (type: {type(args[pos])})" + ) + elif args and "." in str(args[0]): + # we have a version string as first argument + v = cls._parse(args[0]) # type: ignore + for idx, key in enumerate( + ("major", "minor", "patch", "prerelease", "build") + ): + verlist[idx] = v[key] + else: + for index, item in enumerate(args): + verlist[index] = args[index] # type: ignore + # Build a dictionary of the arguments except prerelease and build - version_parts = {"major": int(major), "minor": int(minor), "patch": int(patch)} + try: + version_parts = { + # Prefer major, minor, and patch arguments over args + "major": int(major or verlist[0] or 0), + "minor": int(minor or verlist[1] or 0), + "patch": int(patch or verlist[2] or 0), + } + except ValueError: + raise ValueError( + "Expected integer or integer string for major, minor, or patch" + ) for name, value in version_parts.items(): if value < 0: @@ -126,6 +199,9 @@ def __init__( "{!r} is negative. A version can only be positive.".format(name) ) + prerelease = cls._ensure_str(prerelease or verlist[3]) # type: ignore + build = cls._ensure_str(build or verlist[4]) # type: ignore + self._major = version_parts["major"] self._minor = version_parts["minor"] self._patch = version_parts["patch"] @@ -155,6 +231,43 @@ def cmp_prerelease_tag(a, b): else: return _cmp(len(a), len(b)) + @classmethod + def _ensure_str( + cls, s: Optional[StringOrInt], encoding="UTF-8" + ) -> Optional[StringOrInt]: + """ + Ensures string type regardless if argument type is str or bytes. + + :param s: the string (or None) + :param encoding: the encoding, default to "UTF-8" + :return: a Unicode string (or None) + """ + if isinstance(s, bytes): + return cast(str, s.decode(encoding)) + return s + + @classmethod + def _parse(cls, version: String) -> Dict: + """ + Parse version string and return version parts. + + :param version: version string + :return: a dictionary with version parts + :raises ValueError: if version is invalid + :raises TypeError: if version contains unexpected type + + >>> semver.Version.parse('3.4.5-pre.2+build.4') + Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') + """ + version = cast(str, cls._ensure_str(version)) + if not isinstance(version, String.__args__): # type: ignore + raise TypeError(f"not expecting type {type(version)!r}") + match = cls._REGEX.match(version) + if match is None: + raise ValueError(f"{version!r} is not valid SemVer string") + + return cast(dict, match.groupdict()) + @property def major(self) -> int: """The major part of a version (read-only).""" @@ -227,8 +340,7 @@ def to_dict(self) -> VersionDict: ``patch``, ``prerelease``, and ``build``. >>> semver.Version(3, 2, 1).to_dict() - OrderedDict([('major', 3), ('minor', 2), ('patch', 1), \ -('prerelease', None), ('build', None)]) + OrderedDict([('major', 3), ('minor', 2), ('patch', 1), ('prerelease', None), ('build', None)]) # noqa: E501 """ return collections.OrderedDict( ( @@ -269,13 +381,11 @@ def bump_major(self) -> "Version": :return: new object with the raised major part - - >>> ver = semver.parse("3.4.5") - >>> ver.bump_major() + >>> semver.Version("3.4.5").bump_major() Version(major=4, minor=0, patch=0, prerelease=None, build=None) """ cls = type(self) - return cls(self._major + 1) + return cls(major=self._major + 1) def bump_minor(self) -> "Version": """ @@ -284,12 +394,11 @@ def bump_minor(self) -> "Version": :return: new object with the raised minor part - >>> ver = semver.parse("3.4.5") - >>> ver.bump_minor() + >>> semver.Version("3.4.5").bump_minor() Version(major=3, minor=5, patch=0, prerelease=None, build=None) """ cls = type(self) - return cls(self._major, self._minor + 1) + return cls(major=self._major, minor=self._minor + 1) def bump_patch(self) -> "Version": """ @@ -298,12 +407,11 @@ def bump_patch(self) -> "Version": :return: new object with the raised patch part - >>> ver = semver.parse("3.4.5") - >>> ver.bump_patch() + >>> semver.Version("3.4.5").bump_patch() Version(major=3, minor=4, patch=6, prerelease=None, build=None) """ cls = type(self) - return cls(self._major, self._minor, self._patch + 1) + return cls(major=self._major, minor=self._minor, patch=self._patch + 1) def bump_prerelease(self, token: Optional[str] = "rc") -> "Version": """ @@ -323,17 +431,13 @@ def bump_prerelease(self, token: Optional[str] = "rc") -> "Version": 'rc.1' """ cls = type(self) - if self._prerelease is not None: - prerelease = self._prerelease - elif token == "": - prerelease = "0" - elif token is None: - prerelease = "rc.0" - else: - prerelease = str(token) + ".0" - - prerelease = cls._increment_string(prerelease) - return cls(self._major, self._minor, self._patch, prerelease) + prerelease = cls._increment_string(self._prerelease or (token or "rc") + ".0") + return cls( + major=self._major, + minor=self._minor, + patch=self._patch, + prerelease=prerelease, + ) def bump_build(self, token: Optional[str] = "build") -> "Version": """ @@ -344,35 +448,18 @@ def bump_build(self, token: Optional[str] = "build") -> "Version": :return: new :class:`Version` object with the raised build part. The original object is not modified. - >>> ver = semver.parse("3.4.5-rc.1+build.9") - >>> ver.bump_build() - Version(major=3, minor=4, patch=5, prerelease='rc.1', \ -build='build.10') + >>> semver.Version("3.4.5-rc.1+build.9").bump_build() + Version(major=3, minor=4, patch=5, prerelease='rc.1', build='build.10') # noqa: E501 """ cls = type(self) - if self._build is not None: - build = self._build - elif token == "": - build = "0" - elif token is None: - build = "build.0" - else: - build = str(token) + ".0" - - # self._build or (token or "build") + ".0" - build = cls._increment_string(build) - if self._build is not None: - build = self._build - elif token == "": - build = "0" - elif token is None: - build = "build.0" - else: - build = str(token) + ".0" - - # self._build or (token or "build") + ".0" - build = cls._increment_string(build) - return cls(self._major, self._minor, self._patch, self._prerelease, build) + build = cls._increment_string(self._build or (token or "build") + ".0") + return cls( + major=self._major, + minor=self._minor, + patch=self._patch, + prerelease=self._prerelease, + build=build, + ) def compare(self, other: Comparable) -> int: """ @@ -382,13 +469,13 @@ def compare(self, other: Comparable) -> int: :return: The return value is negative if ver1 < ver2, zero if ver1 == ver2 and strictly positive if ver1 > ver2 - >>> semver.compare("2.0.0") + >>> semver.Version("1.0.0").compare("2.0.0") -1 - >>> semver.compare("1.0.0") - 1 - >>> semver.compare("2.0.0") + >>> semver.Version("1.0.0").compare("1.0.0") 0 - >>> semver.compare(dict(major=2, minor=0, patch=0)) + >>> semver.Version("1.0.0").compare("0.1.0") + -1 + >>> semver.Version("2.0.0").compare(dict(major=2, minor=0, patch=0)) 0 """ cls = type(self) @@ -434,12 +521,12 @@ def next_version(self, part: str, prerelease_token: str = "rc") -> "Version": "preprelease" part. It gives you the next patch version of the prerelease, for example: - >>> str(semver.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: new object with the appropriate part raised + + >>> str(semver.Version("0.1.4").next_version("prerelease")) + '0.1.5-rc.1' """ cls = type(self) # "build" is currently not used, that's why we use [:-1] @@ -500,12 +587,12 @@ def __getitem__( is undefined, it will throw an index error. Negative indices are not supported. - :param Union[int, slice] index: a positive integer indicating the + :param index: a positive integer indicating the offset or a :func:`slice` object :raises IndexError: if index is beyond the range or a part is None :return: the requested part of the version at position index - >>> ver = semver.Version.parse("3.4.5") + >>> ver = semver.Version("3.4.5") >>> ver[0], ver[1], ver[2] (3, 4, 5) """ @@ -535,11 +622,11 @@ def __repr__(self) -> str: return "%s(%s)" % (type(self).__name__, s) def __str__(self) -> str: - version = "%d.%d.%d" % (self.major, self.minor, self.patch) + version = f"{self.major:d}.{self.minor:d}.{self.patch:d}" if self.prerelease: - version += "-%s" % self.prerelease + version += f"-{self.prerelease}" if self.build: - version += "+%s" % self.build + version += f"+{self.build}" return version def __hash__(self) -> int: @@ -551,11 +638,11 @@ def finalize_version(self) -> "Version": :return: a new instance with the finalized version string - >>> str(semver.Version.parse('1.2.3-rc.5').finalize_version()) + >>> str(semver.Version('1.2.3-rc.5').finalize_version()) '1.2.3' """ cls = type(self) - return cls(self.major, self.minor, self.patch) + return cls(major=self.major, minor=self.minor, patch=self.patch) def match(self, match_expr: str) -> bool: """ @@ -570,9 +657,9 @@ def match(self, match_expr: str) -> bool: ``!=`` not equal :return: True if the expression matches the version, otherwise False - >>> semver.Version.parse("2.0.0").match(">=1.0.0") + >>> semver.Version("2.0.0").match(">=1.0.0") True - >>> semver.Version.parse("1.0.0").match(">1.0.0") + >>> semver.Version("1.0.0").match(">1.0.0") False >>> semver.Version.parse("4.0.4").match("4.0.4") True @@ -631,9 +718,8 @@ def parse( :raises ValueError: if version is invalid :raises TypeError: if version contains the wrong type - >>> semver.Version.parse('3.4.5-pre.2+build.4') - Version(major=3, minor=4, patch=5, \ -prerelease='pre.2', build='build.4') + >>> semver.Version('3.4.5-pre.2+build.4') + Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') # noqa: E501 """ if isinstance(version, bytes): version = version.decode("UTF-8") diff --git a/tests/test_semver.py b/tests/test_semver.py index 782d5c79..f7321c34 100644 --- a/tests/test_semver.py +++ b/tests/test_semver.py @@ -56,13 +56,10 @@ def test_should_be_able_to_use_strings_as_major_minor_patch(): assert Version("1", "2", "3") == Version(1, 2, 3) -def test_using_non_numeric_string_as_major_minor_patch_throws(): +@pytest.mark.parametrize("ver", [("a"), (1, "a"), (1, 2, "a")]) +def test_using_non_numeric_string_as_major_minor_patch_throws(ver): with pytest.raises(ValueError): - Version("a") - with pytest.raises(ValueError): - Version(1, "a") - with pytest.raises(ValueError): - Version(1, 2, "a") + Version(*ver) def test_should_be_able_to_use_integers_as_prerelease_build(): @@ -82,6 +79,78 @@ def test_versioninfo_compare_should_raise_when_passed_invalid_value(): Version(1, 2, 3).compare(4) +def test_should_raise_when_too_many_arguments(): + with pytest.raises(ValueError, match=".* more than 5 arguments .*"): + Version(1, 2, 3, 4, 5, 6) + + +def test_should_raise_when_incompatible_type(): + with pytest.raises(TypeError, match="not expecting type .*"): + Version.parse(complex(42)) + with pytest.raises(TypeError, match="not expecting type .*"): + Version(complex(42)) + + +def test_should_raise_when_string_and_args(): + with pytest.raises(ValueError): + Version("1.2.3", 5) + + +@pytest.mark.parametrize( + "ver, expected", + [ + (tuple(), "0.0.0"), + (("1"), "1.0.0"), + ((1, "2"), "1.2.0"), + ((1, 2, "3"), "1.2.3"), + ((b"1", b"2", b"3"), "1.2.3"), + ((1, 2, 3, None), "1.2.3"), + ((1, 2, 3, None, None), "1.2.3"), + ((1, 2, 3, "p1"), "1.2.3-p1"), + ((1, 2, 3, b"p1"), "1.2.3-p1"), + ((1, 2, 3, "p1", b"build1"), "1.2.3-p1+build1"), + ], +) +def test_should_allow_compatible_types(ver, expected): + v = Version(*ver) + assert expected == str(v) + + +@pytest.mark.parametrize( + "ver, kwargs, expected", + [ + ((), dict(major=None), "0.0.0"), + ((), dict(major=10), "10.0.0"), + ((1,), dict(major=10), "10.0.0"), + ((1, 2), dict(major=10), "10.2.0"), + ((1, 2, 3), dict(major=10), "10.2.3"), + ((1, 2), dict(major=10, minor=11), "10.11.0"), + ((1, 2, 3), dict(major=10, minor=11, patch=12), "10.11.12"), + ((1, 2, 3, 4), dict(major=10, minor=11, patch=12), "10.11.12-4"), + ( + (1, 2, 3, 4, 5), + dict(major=10, minor=11, patch=12, prerelease=13), + "10.11.12-13+5", + ), + ( + (1, 2, 3, 4, 5), + dict(major=10, minor=11, patch=12, prerelease=13, build=14), + "10.11.12-13+14", + ), + # + ((1,), dict(major=None, minor=None, patch=None), "1.0.0"), + ], +) +def test_should_allow_overwrite_with_keywords(ver, kwargs, expected): + v = Version(*ver, **kwargs) + assert expected == str(v) + + +def test_should_raise_when_incompatible_semver_string(): + with pytest.raises(ValueError, match=".* is not valid Sem[vV]er string"): + Version("1.2") + + @pytest.mark.parametrize( "old, new", [ From 56480b308b0f49d3a58dd3dd08e04176d72ae290 Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Fri, 20 Nov 2020 21:47:43 +0100 Subject: [PATCH 3/8] Apply suggestions from code review Co-authored-by: Thomas Laferriere --- src/semver/version.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/semver/version.py b/src/semver/version.py index b0198143..dc2eb9d6 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -199,8 +199,14 @@ def _check_types(*args): "{!r} is negative. A version can only be positive.".format(name) ) - prerelease = cls._ensure_str(prerelease or verlist[3]) # type: ignore - build = cls._ensure_str(build or verlist[4]) # type: ignore + if isinstance(prerelease, int): + self._prerelease = prerelease + else: + self._prerelease = cls._ensure_str(prerelease or verlist[3]) + if isinstance(build, int): + self._build = build + else: + self._build = cls._ensure_str(build or verlist[4]) self._major = version_parts["major"] self._minor = version_parts["minor"] @@ -232,9 +238,7 @@ def cmp_prerelease_tag(a, b): return _cmp(len(a), len(b)) @classmethod - def _ensure_str( - cls, s: Optional[StringOrInt], encoding="UTF-8" - ) -> Optional[StringOrInt]: + def _ensure_str(cls, s: Optional[String], encoding="UTF-8") -> Optional[str]: """ Ensures string type regardless if argument type is str or bytes. From 7524377ed108f46bcc3178311b840972e89d1d62 Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Fri, 20 Nov 2020 22:05:20 +0100 Subject: [PATCH 4/8] Insert classmethod _enforce_str(cls, s: Optional[StringOrInt]) Used to catch integer values, but delegate anything else to _ensure_str --- src/semver/version.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/semver/version.py b/src/semver/version.py index dc2eb9d6..9371bce3 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -199,20 +199,11 @@ def _check_types(*args): "{!r} is negative. A version can only be positive.".format(name) ) - if isinstance(prerelease, int): - self._prerelease = prerelease - else: - self._prerelease = cls._ensure_str(prerelease or verlist[3]) - if isinstance(build, int): - self._build = build - else: - self._build = cls._ensure_str(build or verlist[4]) - self._major = version_parts["major"] self._minor = version_parts["minor"] self._patch = version_parts["patch"] - self._prerelease = None if prerelease is None else str(prerelease) - self._build = None if build is None else str(build) + self._prerelease = cls._enforce_str(prerelease or verlist[3]) + self._build = cls._enforce_str(build or verlist[4]) @classmethod def _nat_cmp(cls, a, b): # TODO: type hints @@ -237,6 +228,18 @@ def cmp_prerelease_tag(a, b): else: return _cmp(len(a), len(b)) + @classmethod + def _enforce_str(cls, s: Optional[StringOrInt]) -> Optional[str]: + """ + Forces input to be string, regardless of int, bytes, or string. + + :param s: a string, integer or None + :return: a Unicode string (or None) + """ + if isinstance(s, int): + return str(s) + return cls._ensure_str(s) + @classmethod def _ensure_str(cls, s: Optional[String], encoding="UTF-8") -> Optional[str]: """ From fe64bfb99c9083db418458853a6b024f8c016d8a Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Fri, 20 Nov 2020 23:04:46 +0100 Subject: [PATCH 5/8] Use zip to merge two iterators --- src/semver/version.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/semver/version.py b/src/semver/version.py index 9371bce3..056750d3 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -151,12 +151,12 @@ def _check_types(*args): (int, str, bytes), # major (int, str, bytes), # minor (int, str, bytes), # patch - (str, bytes, int, type(None)), # prerelease - (str, bytes, int, type(None)), # build + (int, str, bytes, type(None)), # prerelease + (int, str, bytes, type(None)), # build ) return [ - isinstance(item, allowed_types_in_args[i]) - for i, item in enumerate(args) + isinstance(item, expected_type) + for item, expected_type in zip(args, allowed_types_in_args) ] cls = self.__class__ From 26ab6d042e3a1585d749174d8e4dcd9856d4545b Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Sat, 21 Nov 2020 19:26:20 +0100 Subject: [PATCH 6/8] Revamp code * Introduce new class variables VERSIONPARTS, VERSIONPARTDEFAULTS, and ALLOWED_TYPES * Simplify __init__; outsource some functionality like type checking into different functions * Use dict merging between *args and version components --- docs/usage/compare-versions.rst | 2 +- docs/usage/create-a-version.rst | 5 +- src/semver/version.py | 206 +++++++++++++++++++++++--------- 3 files changed, 152 insertions(+), 61 deletions(-) diff --git a/docs/usage/compare-versions.rst b/docs/usage/compare-versions.rst index b42ba1a7..be0ed768 100644 --- a/docs/usage/compare-versions.rst +++ b/docs/usage/compare-versions.rst @@ -67,7 +67,7 @@ To compare two versions depends on your type: >>> v > "1.0" Traceback (most recent call last): ... - ValueError: 1.0 is not valid SemVer string + ValueError: '1.0' is not valid SemVer string * **A** :class:`Version ` **type and a** :func:`dict` diff --git a/docs/usage/create-a-version.rst b/docs/usage/create-a-version.rst index 84f11131..9c5df52f 100644 --- a/docs/usage/create-a-version.rst +++ b/docs/usage/create-a-version.rst @@ -93,6 +93,9 @@ arguments: ValueError: You cannot pass a string and additional positional arguments +Using Deprecated Functions to Create a Version +---------------------------------------------- + The old, deprecated module level functions are still available but using them are discoraged. They are available to convert old code to semver3. @@ -123,4 +126,4 @@ Depending on your use case, the following methods are available: >>> semver.parse("1.2") Traceback (most recent call last): ... - ValueError: 1.2 is not valid SemVer string + ValueError: '1.2' is not valid SemVer string diff --git a/src/semver/version.py b/src/semver/version.py index 056750d3..2cb08517 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -27,7 +27,7 @@ ) # These types are required here because of circular imports -Comparable = Union["Version", Dict[str, VersionPart], Collection[VersionPart], str] +Comparable = Union["Version", Dict[str, VersionPart], Collection[VersionPart], String] Comparator = Callable[["Version", Comparable], bool] T = TypeVar("T", bound="Version") @@ -65,7 +65,7 @@ class Version: * a maximum length of 5 items that comprehend the major, minor, patch, prerelease, or build parts. - * a str or bytes string that contains a valid semver + * a str or bytes string at first position that contains a valid semver version string. :param major: version when you make incompatible API changes. :param minor: version when you add functionality in @@ -85,6 +85,21 @@ class Version: Version(major=2, minor=3, patch=4, prerelease=None, build="build.2") """ + #: The name of the version parts + VERSIONPARTS: Tuple[str, str, str, str, str] = ( + "major", "minor", "patch", "prerelease", "build" + ) + #: The default values for each part (position match with ``VERSIONPARTS``): + VERSIONPARTDEFAULTS: VersionTuple = (0, 0, 0, None, None) + #: The allowed types for each part (position match with ``VERSIONPARTS``): + ALLOWED_TYPES = ( + (int, str, bytes), # major + (int, str, bytes), # minor + (int, str, bytes), # patch + (int, str, bytes, type(None)), # prerelease + (int, str, bytes, type(None)), # build + ) + __slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build") #: The names of the different parts of a version @@ -125,6 +140,45 @@ class Version: re.VERBOSE, ) + def _check_types(self, *args: Tuple) -> List[bool]: + """ + Check if the given arguments conform to the types in ``ALLOWED_TYPES``. + + :return: bool for each position + """ + cls = self.__class__ + return [ + isinstance(item, expected_type) + for item, expected_type in zip(args, cls.ALLOWED_TYPES) + ] + + def _raise_if_args_are_invalid(self, *args): + """ + Checks conditions for positional arguments. For example: + + * No more than 5 arguments. + * If first argument is a string, contains a dot, and there + are more arguments. + * Arguments have invalid types. + + :raises ValueError: if more arguments than 5 or if first argument + is a string, contains a dot, and there are more arguments. + :raises TypeError: if there are invalid types. + """ + if args and len(args) > 5: + raise ValueError("You cannot pass more than 5 arguments to Version") + elif len(args) > 1 and "." in str(args[0]): + raise ValueError( + "You cannot pass a string and additional positional arguments" + ) + types_in_args = self._check_types(*args) + if not all(types_in_args): + pos = types_in_args.index(False) + raise TypeError( + "not expecting type in argument position " + f"{pos} (type: {type(args[pos])})" + ) + def __init__( self, *args: Tuple[ @@ -140,70 +194,75 @@ def __init__( prerelease: Optional[Union[String, int]] = None, build: Optional[Union[String, int]] = None, ): - def _check_types(*args): - if args and len(args) > 5: - raise ValueError("You cannot pass more than 5 arguments to Version") - elif len(args) > 1 and "." in str(args[0]): - raise ValueError( - "You cannot pass a string and additional positional arguments" - ) - allowed_types_in_args = ( - (int, str, bytes), # major - (int, str, bytes), # minor - (int, str, bytes), # patch - (int, str, bytes, type(None)), # prerelease - (int, str, bytes, type(None)), # build - ) - return [ - isinstance(item, expected_type) - for item, expected_type in zip(args, allowed_types_in_args) - ] + # + # The algorithm to support different Version calls is this: + # + # 1. Check first, if there are invalid calls. For example + # more than 5 items in args or a unsupported combination + # of args and version part arguments (major, minor, etc.) + # If yes, raise an exception. + # + # 2. Create a dictargs dict: + # a. If the first argument is a version string which contains + # a dot it's likely it's a semver string. Try to convert + # them into a dict and save it to dictargs. + # b. If the first argument is not a version string, try to + # create the dictargs from the args argument. + # + # 3. Create a versiondict from the version part arguments. + # This contains only items if the argument is not None. + # + # 4. Merge the two dicts, versiondict overwrites dictargs. + # In other words, if the user specifies Version(1, major=2) + # the major=2 has precedence over the 1. + # + # 5. Set all version components from versiondict. If the key + # doesn't exist, set a default value. cls = self.__class__ - verlist: List[Optional[StringOrInt]] = [None, None, None, None, None] + # (1) check combinations and types + self._raise_if_args_are_invalid(*args) - types_in_args = _check_types(*args) - if not all(types_in_args): - pos = types_in_args.index(False) - raise TypeError( - "not expecting type in argument position " - f"{pos} (type: {type(args[pos])})" - ) - elif args and "." in str(args[0]): - # we have a version string as first argument - v = cls._parse(args[0]) # type: ignore - for idx, key in enumerate( - ("major", "minor", "patch", "prerelease", "build") - ): - verlist[idx] = v[key] + # (2) First argument was a string + if args and args[0] and "." in cls._enforce_str(args[0]): # type: ignore + dictargs = cls._parse(cast(String, args[0])) else: - for index, item in enumerate(args): - verlist[index] = args[index] # type: ignore + dictargs = dict(zip(cls.VERSIONPARTS, args)) - # Build a dictionary of the arguments except prerelease and build - try: - version_parts = { - # Prefer major, minor, and patch arguments over args - "major": int(major or verlist[0] or 0), - "minor": int(minor or verlist[1] or 0), - "patch": int(patch or verlist[2] or 0), - } - except ValueError: - raise ValueError( - "Expected integer or integer string for major, minor, or patch" + # (3) Only include part in versiondict if value is not None + versiondict = { + part: value + for part, value in zip( + cls.VERSIONPARTS, (major, minor, patch, prerelease, build) ) + if value is not None + } - for name, value in version_parts.items(): - if value < 0: - raise ValueError( - "{!r} is negative. A version can only be positive.".format(name) - ) + # (4) Order here is important: versiondict overwrites dictargs + versiondict = {**dictargs, **versiondict} # type: ignore - self._major = version_parts["major"] - self._minor = version_parts["minor"] - self._patch = version_parts["patch"] - self._prerelease = cls._enforce_str(prerelease or verlist[3]) - self._build = cls._enforce_str(build or verlist[4]) + # (5) Set all version components: + self._major = cls._ensure_int( + cast(StringOrInt, versiondict.get("major", cls.VERSIONPARTDEFAULTS[0])) + ) + self._minor = cls._ensure_int( + cast(StringOrInt, versiondict.get("minor", cls.VERSIONPARTDEFAULTS[1])) + ) + self._patch = cls._ensure_int( + cast(StringOrInt, versiondict.get("patch", cls.VERSIONPARTDEFAULTS[2])) + ) + self._prerelease = cls._enforce_str( + cast( + Optional[StringOrInt], + versiondict.get("prerelease", cls.VERSIONPARTDEFAULTS[3]), + ) + ) + self._build = cls._enforce_str( + cast( + Optional[StringOrInt], + versiondict.get("build", cls.VERSIONPARTDEFAULTS[4]), + ) + ) @classmethod def _nat_cmp(cls, a, b): # TODO: type hints @@ -228,6 +287,31 @@ def cmp_prerelease_tag(a, b): else: return _cmp(len(a), len(b)) + @classmethod + def _ensure_int(cls, value: StringOrInt) -> int: + """ + Ensures integer value type regardless if argument type is str or bytes. + Otherwise raise ValueError. + + :param value: + :raises ValueError: Two conditions: + * If value is not an integer or cannot be converted. + * If value is negative. + :return: the converted value as integer + """ + try: + value = int(value) + except ValueError: + raise ValueError( + "Expected integer or integer string for major, minor, or patch" + ) + + if value < 0: + raise ValueError( + f"Argument {value} is negative. A version can only be positive." + ) + return value + @classmethod def _enforce_str(cls, s: Optional[StringOrInt]) -> Optional[str]: """ @@ -486,8 +570,12 @@ def compare(self, other: Comparable) -> int: 0 """ cls = type(self) + + # See https://github.com/python/mypy/issues/4019 if isinstance(other, String.__args__): # type: ignore - other = cls.parse(other) + if "." not in cast(str, cls._ensure_str(other)): + raise ValueError("Expected semver version string.") + other = cls(other) elif isinstance(other, dict): other = cls(**other) elif isinstance(other, (tuple, list)): From 4a704263118607c5bd1fa357103fa895cfa47985 Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Tue, 7 Mar 2023 08:24:29 +0100 Subject: [PATCH 7/8] Reformat with black --- src/semver/version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/semver/version.py b/src/semver/version.py index 2cb08517..af3805c7 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -87,7 +87,11 @@ class Version: #: The name of the version parts VERSIONPARTS: Tuple[str, str, str, str, str] = ( - "major", "minor", "patch", "prerelease", "build" + "major", + "minor", + "patch", + "prerelease", + "build", ) #: The default values for each part (position match with ``VERSIONPARTS``): VERSIONPARTDEFAULTS: VersionTuple = (0, 0, 0, None, None) From cf138ebc49046f7e947264786854fa5bb979689d Mon Sep 17 00:00:00 2001 From: Tom Schraitle Date: Tue, 7 Mar 2023 08:26:49 +0100 Subject: [PATCH 8/8] Add missing Type and TypeVar import --- src/semver/version.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/semver/version.py b/src/semver/version.py index af3805c7..8600d7fe 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -13,6 +13,8 @@ Optional, SupportsInt, Tuple, + Type, + TypeVar, Union, cast, )