diff --git a/README.rst b/README.rst index d4f29819..338f15bc 100644 --- a/README.rst +++ b/README.rst @@ -50,7 +50,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 @@ -68,7 +68,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 52a46704..2bf33b19 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -265,3 +265,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.rst b/docs/usage.rst index cb764906..188080e6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -54,17 +54,20 @@ 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:: @@ -100,6 +103,32 @@ 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. @@ -130,17 +159,7 @@ 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 - - -Parsing a Version String ------------------------- - -"Parsing" in this context means to identify the different parts in a string. -Use the function :func:`Version.parse `:: - - >>> Version.parse("3.4.5-pre.2+build.4") - Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4') + ValueError: '1.2' is not valid SemVer string Checking for a Valid Semver Version @@ -167,7 +186,7 @@ parts of a version: .. code-block:: python - >>> v = Version.parse("3.4.5-pre.2+build.4") + >>> v = Version("3.4.5-pre.2+build.4") >>> v.major 3 >>> v.minor @@ -436,7 +455,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/src/semver/_types.py b/src/semver/_types.py index 4f004a29..6ea5152d 100644 --- a/src/semver/_types.py +++ b/src/semver/_types.py @@ -7,4 +7,5 @@ VersionDict = Dict[str, VersionPart] VersionIterator = Iterable[VersionPart] String = Union[str, bytes] +StringOrInt = Union[String, int] F = TypeVar("F", bound=Callable) diff --git a/src/semver/version.py b/src/semver/version.py index 9e02544f..8d25a21e 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -5,23 +5,25 @@ from functools import wraps from typing import ( Any, + Callable, + Collection, Dict, Iterable, + List, Optional, SupportsInt, Tuple, Union, cast, - Callable, - Collection, ) from ._types import ( - VersionTuple, + String, + StringOrInt, VersionDict, VersionIterator, - String, VersionPart, + VersionTuple, ) # These types are required here because of circular imports @@ -57,12 +59,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") @@ -92,14 +110,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: Union[String, int] = None, - build: Union[String, int] = None, + prerelease: StringOrInt = None, + build: StringOrInt = 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: @@ -107,6 +182,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"] @@ -136,6 +214,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).""" @@ -208,8 +323,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( ( @@ -250,13 +364,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": """ @@ -265,12 +377,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": """ @@ -279,12 +390,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: str = "rc") -> "Version": """ @@ -294,14 +404,17 @@ def bump_prerelease(self, token: str = "rc") -> "Version": :param token: defaults to ``rc`` :return: new object with the raised prerelease part - >>> ver = semver.parse("3.4.5") - >>> ver.bump_prerelease() - Version(major=3, minor=4, patch=5, prerelease='rc.2', \ -build=None) + >>> semver.Version("3.4.5").bump_prerelease() + Version(major=3, minor=4, patch=5, prerelease='rc.2', build=None) # noqa: E501 """ cls = type(self) prerelease = cls._increment_string(self._prerelease or (token or "rc") + ".0") - return cls(self._major, self._minor, self._patch, prerelease) + return cls( + major=self._major, + minor=self._minor, + patch=self._patch, + prerelease=prerelease, + ) def bump_build(self, token: str = "build") -> "Version": """ @@ -311,14 +424,18 @@ def bump_build(self, token: str = "build") -> "Version": :param token: defaults to ``build`` :return: new object with the raised build part - >>> 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) build = cls._increment_string(self._build or (token or "build") + ".0") - return cls(self._major, self._minor, self._patch, self._prerelease, build) + return cls( + major=self._major, + minor=self._minor, + patch=self._patch, + prerelease=self._prerelease, + build=build, + ) def compare(self, other: Comparable) -> int: """ @@ -328,13 +445,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) @@ -380,12 +497,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' """ validparts = { "major", @@ -449,12 +566,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) """ @@ -484,11 +601,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: @@ -500,11 +617,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: """ @@ -519,9 +636,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 """ prefix = match_expr[:2] @@ -566,21 +683,10 @@ def parse(cls, version: String) -> "Version": :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") - elif not isinstance(version, String.__args__): # type: ignore - raise TypeError("not expecting type '%s'" % type(version)) - - match = cls._REGEX.match(version) - if match is None: - raise ValueError(f"{version} is not valid SemVer string") - - matched_version_parts: Dict[str, Any] = match.groupdict() - + matched_version_parts: Dict[str, Any] = cls._parse(version) return cls(**matched_version_parts) def replace(self, **parts: Union[int, Optional[str]]) -> "Version": diff --git a/tests/test_semver.py b/tests/test_semver.py index b15bfeaf..99749cd2 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(): @@ -80,3 +77,75 @@ def test_should_versioninfo_isvalid(): def test_versioninfo_compare_should_raise_when_passed_invalid_value(): with pytest.raises(TypeError): 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")