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, ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy