Skip to content

Commit

Permalink
Fix python-semver#303: Fix Version.__init__ method
Browse files Browse the repository at this point in the history
* Allow different variants to call Version
* Adapt the documentation and README
* Adapt and amend tests
* Add changelog entries
* Introduce a (private) _ensure_str class method
  • Loading branch information
tomschr committed Nov 8, 2020
1 parent 6bb8ca6 commit 49c00b9
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 45 deletions.
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/303.doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Prefer :meth:`Version.__init__` over :meth:`Version.parse`
and change examples accordingly.
3 changes: 3 additions & 0 deletions changelog.d/303.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 19 additions & 15 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <semver.version.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
-----------------------------------

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/semver/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
VersionDict = Dict[str, VersionPart]
VersionIterator = Iterable[VersionPart]
String = Union[str, bytes]
StringOrInt = Union[String, int]
F = TypeVar("F", bound=Callable)
172 changes: 150 additions & 22 deletions src/semver/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Any,
Dict,
Iterable,
List,
Optional,
SupportsInt,
Tuple,
Expand All @@ -17,10 +18,11 @@
)

from ._types import (
VersionTuple,
String,
StringOrInt,
VersionDict,
VersionIterator,
String,
VersionTuple,
VersionPart,
)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -142,29 +160,134 @@ 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:
raise ValueError(
"{!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)."""
Expand Down Expand Up @@ -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":
"""
Expand All @@ -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":
"""
Expand All @@ -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":
"""
Expand All @@ -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":
"""
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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":
Expand Down

0 comments on commit 49c00b9

Please sign in to comment.