Skip to content

Commit 1587fa0

Browse files
committed
Fix #303: Fix Version.__init__ method
* Allow different variants to call Version * Adapt the documentation and README * Adapt and amend tests * Add changelog entries TODO: Fix typing errors
1 parent 6bb8ca6 commit 1587fa0

File tree

7 files changed

+146
-39
lines changed

7 files changed

+146
-39
lines changed

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ different parts, use the ``semver.Version.parse`` function:
5050

5151
.. code-block:: python
5252
53-
>>> ver = semver.Version.parse('1.2.3-pre.2+build.4')
53+
>>> ver = semver.Version('1.2.3-pre.2+build.4')
5454
>>> ver.major
5555
1
5656
>>> ver.minor
@@ -68,7 +68,7 @@ returns a new ``semver.Version`` instance with the raised major part:
6868

6969
.. code-block:: python
7070
71-
>>> ver = semver.Version.parse("3.4.5")
71+
>>> ver = semver.Version("3.4.5")
7272
>>> ver.bump_major()
7373
Version(major=4, minor=0, patch=0, prerelease=None, build=None)
7474

changelog.d/303.doc.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Prefer :meth:`Version.__init__` over :meth:`Version.parse`
2+
and change examples accordingly.

changelog.d/303.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Extend :meth:`Version.__init__` initializer. It allows
2+
now to have positional and keyword arguments. The keyword
3+
arguments overwrites any positional arguments.

docs/usage.rst

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,12 @@ A :class:`~semver.version.Version` instance can be created in different ways:
5757
* From a Unicode string::
5858

5959
>>> from semver.version import Version
60-
>>> Version.parse("3.4.5-pre.2+build.4")
60+
>>> Version("3.4.5-pre.2+build.4")
6161
Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4')
62-
>>> Version.parse(u"5.3.1")
63-
Version(major=5, minor=3, patch=1, prerelease=None, build=None)
6462

6563
* From a byte string::
6664

67-
>>> Version.parse(b"2.3.4")
65+
>>> Version(b"2.3.4")
6866
Version(major=2, minor=3, patch=4, prerelease=None, build=None)
6967

7068
* From individual parts by a dictionary::
@@ -100,6 +98,22 @@ A :class:`~semver.version.Version` instance can be created in different ways:
10098
>>> Version("3", "5", 6)
10199
Version(major=3, minor=5, patch=6, prerelease=None, build=None)
102100

101+
It is possible to combine, positional and keyword arguments. In
102+
some use cases you have a fixed version string, but would like to
103+
replace parts of them. For example::
104+
105+
>>> Version(1, 2, 3, major=2, build="b2")
106+
Version(major=2, minor=2, patch=3, prerelease=None, build='b2')
107+
108+
In some cases it could be helpful to pass nothing to :class:`Version`::
109+
110+
>>> Version()
111+
Version(major=0, minor=0, patch=0, prerelease=None, build=None)
112+
113+
114+
Using Deprecated Functions to Create a Version
115+
----------------------------------------------
116+
103117
The old, deprecated module level functions are still available but
104118
using them are discoraged. They are available to convert old code
105119
to semver3.
@@ -133,16 +147,6 @@ Depending on your use case, the following methods are available:
133147
ValueError: 1.2 is not valid SemVer string
134148

135149

136-
Parsing a Version String
137-
------------------------
138-
139-
"Parsing" in this context means to identify the different parts in a string.
140-
Use the function :func:`Version.parse <semver.version.Version.parse>`::
141-
142-
>>> Version.parse("3.4.5-pre.2+build.4")
143-
Version(major=3, minor=4, patch=5, prerelease='pre.2', build='build.4')
144-
145-
146150
Checking for a Valid Semver Version
147151
-----------------------------------
148152

@@ -167,7 +171,7 @@ parts of a version:
167171

168172
.. code-block:: python
169173
170-
>>> v = Version.parse("3.4.5-pre.2+build.4")
174+
>>> v = Version("3.4.5-pre.2+build.4")
171175
>>> v.major
172176
3
173177
>>> v.minor

src/semver/_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
VersionDict = Dict[str, VersionPart]
88
VersionIterator = Iterable[VersionPart]
99
String = Union[str, bytes]
10+
StringOrInt = Union[String, int]
1011
F = TypeVar("F", bound=Callable)

src/semver/version.py

Lines changed: 109 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
)
1818

1919
from ._types import (
20-
VersionTuple,
20+
String,
21+
StringOrInt,
2122
VersionDict,
2223
VersionIterator,
23-
String,
24+
VersionTuple,
2425
VersionPart,
2526
)
2627

@@ -109,12 +110,28 @@ class Version:
109110
"""
110111
A semver compatible version class.
111112
113+
:param args: a tuple with version information. It can consist of:
114+
115+
* a maximum length of 5 items that comprehend the major,
116+
minor, patch, prerelease, or build.
117+
* a str or bytes string that contains a valid semver
118+
version string.
112119
:param major: version when you make incompatible API changes.
113120
:param minor: version when you add functionality in
114121
a backwards-compatible manner.
115122
:param patch: version when you make backwards-compatible bug fixes.
116123
:param prerelease: an optional prerelease string
117124
:param build: an optional build string
125+
126+
This gives you some options to call the :class:`Version` class.
127+
Precedence has the keyword arguments over the positional arguments.
128+
129+
>>> Version(1, 2, 3)
130+
Version(major=1, minor=2, patch=3, prerelease=None, build=None)
131+
>>> Version("2.3.4-pre.2")
132+
Version(major=2, minor=3, patch=4, prerelease="pre.2", build=None)
133+
>>> Version(major=2, minor=3, patch=4, build="build.2")
134+
Version(major=2, minor=3, patch=4, prerelease=None, build="build.2")
118135
"""
119136

120137
__slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build")
@@ -144,27 +161,92 @@ class Version:
144161

145162
def __init__(
146163
self,
147-
major: SupportsInt,
164+
*args: Tuple[
165+
Union[str, bytes, int],
166+
Optional[int],
167+
Optional[int],
168+
Optional[str],
169+
Optional[str],
170+
],
171+
major: SupportsInt = 0,
148172
minor: SupportsInt = 0,
149173
patch: SupportsInt = 0,
150-
prerelease: Union[String, int] = None,
151-
build: Union[String, int] = None,
174+
prerelease: StringOrInt = None,
175+
build: StringOrInt = None,
152176
):
177+
verlist = [None, None, None, None, None]
178+
179+
if args and "." in str(args[0]):
180+
# we have a version string as first argument
181+
cls = self.__class__
182+
v = cast(dict, cls._parse(args[0])) # type: ignore
183+
self._major = int(v["major"])
184+
self._minor = int(v["minor"])
185+
self._patch = int(v["patch"])
186+
self._prerelease = v["prerelease"]
187+
self._build = v["build"]
188+
return
189+
if args and len(args) > 5:
190+
raise ValueError("You cannot pass more than 5 arguments to Version")
191+
192+
for index, item in enumerate(args):
193+
verlist[index] = args[index] # type: ignore
194+
153195
# Build a dictionary of the arguments except prerelease and build
154-
version_parts = {"major": int(major), "minor": int(minor), "patch": int(patch)}
196+
try:
197+
version_parts = {
198+
# Prefer major, minor, and patch over args
199+
"major": int(major or verlist[0] or 0),
200+
"minor": int(minor or verlist[1] or 0),
201+
"patch": int(patch or verlist[2] or 0),
202+
}
203+
except ValueError:
204+
raise ValueError(
205+
"Expected integer or integer string for major, " "minor, or patch"
206+
)
155207

156208
for name, value in version_parts.items():
157209
if value < 0:
158210
raise ValueError(
159211
"{!r} is negative. A version can only be positive.".format(name)
160212
)
161213

214+
prerelease = prerelease or verlist[3]
215+
build = build or verlist[4]
216+
162217
self._major = version_parts["major"]
163218
self._minor = version_parts["minor"]
164219
self._patch = version_parts["patch"]
165220
self._prerelease = None if prerelease is None else str(prerelease)
166221
self._build = None if build is None else str(build)
167222

223+
@classmethod
224+
def _parse(cls, version: String) -> Dict:
225+
"""
226+
Parse version string to a Version instance.
227+
228+
.. versionchanged:: 2.11.0
229+
Changed method from static to classmethod to
230+
allow subclasses.
231+
232+
:param version: version string
233+
:return: a new :class:`Version` instance
234+
:raises ValueError: if version is invalid
235+
236+
>>> semver.Version.parse('3.4.5-pre.2+build.4')
237+
Version(major=3, minor=4, patch=5, \
238+
prerelease='pre.2', build='build.4')
239+
"""
240+
if isinstance(version, bytes):
241+
version: str = version.decode("UTF-8") # type: ignore
242+
elif not isinstance(version, String.__args__): # type: ignore
243+
raise TypeError(f"not expecting type {type(version)!r}")
244+
match = cls._REGEX.match(cast(str, version))
245+
if match is None:
246+
raise ValueError(f"{version} is not valid SemVer string") # type: ignore
247+
248+
return cast(dict, match.groupdict())
249+
168250
@property
169251
def major(self) -> int:
170252
"""The major part of a version (read-only)."""
@@ -285,7 +367,7 @@ def bump_major(self) -> "Version":
285367
Version(major=4, minor=0, patch=0, prerelease=None, build=None)
286368
"""
287369
cls = type(self)
288-
return cls(self._major + 1)
370+
return cls(major=self._major + 1)
289371

290372
def bump_minor(self) -> "Version":
291373
"""
@@ -299,7 +381,7 @@ def bump_minor(self) -> "Version":
299381
Version(major=3, minor=5, patch=0, prerelease=None, build=None)
300382
"""
301383
cls = type(self)
302-
return cls(self._major, self._minor + 1)
384+
return cls(major=self._major, minor=self._minor + 1)
303385

304386
def bump_patch(self) -> "Version":
305387
"""
@@ -313,7 +395,7 @@ def bump_patch(self) -> "Version":
313395
Version(major=3, minor=4, patch=6, prerelease=None, build=None)
314396
"""
315397
cls = type(self)
316-
return cls(self._major, self._minor, self._patch + 1)
398+
return cls(major=self._major, minor=self._minor, patch=self._patch + 1)
317399

318400
def bump_prerelease(self, token: str = "rc") -> "Version":
319401
"""
@@ -330,7 +412,12 @@ def bump_prerelease(self, token: str = "rc") -> "Version":
330412
"""
331413
cls = type(self)
332414
prerelease = cls._increment_string(self._prerelease or (token or "rc") + ".0")
333-
return cls(self._major, self._minor, self._patch, prerelease)
415+
return cls(
416+
major=self._major,
417+
minor=self._minor,
418+
patch=self._patch,
419+
prerelease=prerelease,
420+
)
334421

335422
def bump_build(self, token: str = "build") -> "Version":
336423
"""
@@ -347,7 +434,13 @@ def bump_build(self, token: str = "build") -> "Version":
347434
"""
348435
cls = type(self)
349436
build = cls._increment_string(self._build or (token or "build") + ".0")
350-
return cls(self._major, self._minor, self._patch, self._prerelease, build)
437+
return cls(
438+
major=self._major,
439+
minor=self._minor,
440+
patch=self._patch,
441+
prerelease=self._prerelease,
442+
build=build,
443+
)
351444

352445
def compare(self, other: Comparable) -> int:
353446
"""
@@ -513,11 +606,11 @@ def __repr__(self) -> str:
513606
return "%s(%s)" % (type(self).__name__, s)
514607

515608
def __str__(self) -> str:
516-
version = "%d.%d.%d" % (self.major, self.minor, self.patch)
609+
version = f"{self.major:d}.{self.minor:d}.{self.patch:d}"
517610
if self.prerelease:
518-
version += "-%s" % self.prerelease
611+
version += f"-{self.prerelease}"
519612
if self.build:
520-
version += "+%s" % self.build
613+
version += f"+{self.build}"
521614
return version
522615

523616
def __hash__(self) -> int:
@@ -533,7 +626,7 @@ def finalize_version(self) -> "Version":
533626
'1.2.3'
534627
"""
535628
cls = type(self)
536-
return cls(self.major, self.minor, self.patch)
629+
return cls(major=self.major, minor=self.minor, patch=self.patch)
537630

538631
def match(self, match_expr: str) -> bool:
539632
"""
@@ -598,13 +691,7 @@ def parse(cls, version: String) -> "Version":
598691
Version(major=3, minor=4, patch=5, \
599692
prerelease='pre.2', build='build.4')
600693
"""
601-
version_str = ensure_str(version)
602-
match = cls._REGEX.match(version_str)
603-
if match is None:
604-
raise ValueError(f"{version_str} is not valid SemVer string")
605-
606-
matched_version_parts: Dict[str, Any] = match.groupdict()
607-
694+
matched_version_parts: Dict[str, Any] = cls._parse(version)
608695
return cls(**matched_version_parts)
609696

610697
def replace(self, **parts: Union[int, Optional[str]]) -> "Version":

tests/test_semver.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,13 @@ def test_should_versioninfo_isvalid():
8080
def test_versioninfo_compare_should_raise_when_passed_invalid_value():
8181
with pytest.raises(TypeError):
8282
Version(1, 2, 3).compare(4)
83+
84+
85+
def test_should_raise_when_too_many_arguments():
86+
with pytest.raises(ValueError, match=".* more than 5 arguments .*"):
87+
Version(1, 2, 3, 4, 5, 6)
88+
89+
90+
def test_should_raise_when_incompatible_type():
91+
with pytest.raises(TypeError, match="not expecting type .*"):
92+
Version.parse(complex(42))

0 commit comments

Comments
 (0)