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/usage.rst b/docs/usage.rst index cb764906..d89ea998 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -57,14 +57,12 @@ A :class:`~semver.version.Version` instance can be created in different ways: * From a Unicode string:: >>> from semver.version import Version - >>> Version.parse("3.4.5-pre.2+build.4") + >>> 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 +98,22 @@ 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') + +In some cases it could be helpful to pass nothing to :class:`Version`:: + + >>> Version() + Version(major=0, minor=0, patch=0, prerelease=None, build=None) + + +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. @@ -133,16 +147,6 @@ Depending on your use case, the following methods are available: 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') - - Checking for a Valid Semver Version ----------------------------------- @@ -167,7 +171,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 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 4633f4bc..c7347ac6 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -7,6 +7,7 @@ Any, Dict, Iterable, + List, Optional, SupportsInt, Tuple, @@ -17,10 +18,11 @@ ) from ._types import ( - VersionTuple, + String, + StringOrInt, VersionDict, VersionIterator, - String, + VersionTuple, VersionPart, ) @@ -109,12 +111,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. + * 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") @@ -142,16 +160,77 @@ class Version: re.VERBOSE, ) + # Decision table: + # Version("1.1.1", 1) => error + # Version(1, 2, 3, 4, 5, 6) => error + # ----- + # Version("1.1.1", major=2) => okay + 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 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: @@ -159,12 +238,56 @@ def __init__( "{!r} is negative. A version can only be positive.".format(name) ) + # reveal_type(verlist[3]) + # -> Union[builtins.str, builtins.bytes, builtins.int, None] + 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"] self._prerelease = None if prerelease is None else str(prerelease) self._build = None if build is None else str(build) + @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 to a Version instance. + + .. versionchanged:: 2.11.0 + Changed method from static to classmethod to + allow subclasses. + + :param version: version string + :return: a new :class:`Version` instance + :raises ValueError: if version is invalid + + >>> 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(cast(str, version)) + if match is None: + raise ValueError(f"{version} is not valid SemVer string") + + return cast(dict, match.groupdict()) + @property def major(self) -> int: """The major part of a version (read-only).""" @@ -285,7 +408,7 @@ def bump_major(self) -> "Version": 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": """ @@ -299,7 +422,7 @@ def bump_minor(self) -> "Version": 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": """ @@ -313,7 +436,7 @@ def bump_patch(self) -> "Version": 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": """ @@ -330,7 +453,12 @@ def bump_prerelease(self, token: str = "rc") -> "Version": """ 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": """ @@ -347,7 +475,13 @@ def bump_build(self, token: str = "build") -> "Version": """ 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: """ @@ -513,11 +647,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: @@ -533,7 +667,7 @@ def finalize_version(self) -> "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: """ @@ -598,13 +732,7 @@ def parse(cls, version: String) -> "Version": Version(major=3, minor=4, patch=5, \ prerelease='pre.2', build='build.4') """ - version_str = ensure_str(version) - match = cls._REGEX.match(version_str) - if match is None: - raise ValueError(f"{version_str} 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")