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/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 `, 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 3acb4c03..9c5df52f 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,31 @@ 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 + + +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. @@ -97,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/_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..8600d7fe 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -5,29 +5,31 @@ from functools import wraps from typing import ( Any, + Callable, + Collection, Dict, Iterable, + List, Optional, SupportsInt, Tuple, - Union, - cast, - Callable, - Collection, Type, TypeVar, + Union, + cast, ) from ._types import ( - VersionTuple, + String, + StringOrInt, VersionDict, VersionIterator, - String, VersionPart, + VersionTuple, ) # 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") @@ -61,14 +63,49 @@ 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 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 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") """ + #: 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 @@ -109,28 +146,129 @@ 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, - 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, ): - # Build a dictionary of the arguments except prerelease and build - version_parts = {"major": int(major), "minor": int(minor), "patch": int(patch)} + # + # 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__ + # (1) check combinations and types + self._raise_if_args_are_invalid(*args) + + # (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: + dictargs = dict(zip(cls.VERSIONPARTS, args)) - for name, value in version_parts.items(): - if value < 0: - raise ValueError( - "{!r} is negative. A version can only be positive.".format(name) - ) + # (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 + } - 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) + # (4) Order here is important: versiondict overwrites dictargs + versiondict = {**dictargs, **versiondict} # type: ignore + + # (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 @@ -155,6 +293,78 @@ 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]: + """ + 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]: + """ + 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 +437,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 +478,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 +491,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 +504,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 +528,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 +545,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,18 +566,22 @@ 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) + + # 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)): @@ -434,12 +622,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 +688,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 +723,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 +739,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 +758,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 +819,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", [