diff --git a/changelog.d/pr359.feature.rst b/changelog.d/pr359.feature.rst new file mode 100644 index 00000000..5c18c9d2 --- /dev/null +++ b/changelog.d/pr359.feature.rst @@ -0,0 +1,2 @@ +Add optional parameter ``optional_minor_and_patch`` in :meth:`.Version.parse` to allow optional +minor and patch parts. diff --git a/docs/usage/parse-version-string.rst b/docs/usage/parse-version-string.rst index ddd421e7..0a39c8a3 100644 --- a/docs/usage/parse-version-string.rst +++ b/docs/usage/parse-version-string.rst @@ -6,3 +6,10 @@ 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') + +Set the parameter ``optional_minor_and_patch=True`` to allow optional +minor and patch parts. Optional parts are set to zero. By default (False), the +version string to parse has to follow the semver specification:: + + >>> Version.parse("1.2", optional_minor_and_patch=True) + Version(major=1, minor=2, patch=0, prerelease=None, build=None) diff --git a/src/semver/version.py b/src/semver/version.py index 9e02544f..096acdf2 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -68,15 +68,19 @@ class Version: __slots__ = ("_major", "_minor", "_patch", "_prerelease", "_build") #: Regex for number in a prerelease _LAST_NUMBER = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+") - #: Regex for a semver version - _REGEX = re.compile( + #: Regex template for a semver version + _REGEX_TEMPLATE = \ r""" ^ (?P0|[1-9]\d*) - \. - (?P0|[1-9]\d*) - \. - (?P0|[1-9]\d*) + (?: + \. + (?P0|[1-9]\d*) + (?: + \. + (?P0|[1-9]\d*) + ){opt_patch} + ){opt_minor} (?:-(?P (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* @@ -86,7 +90,15 @@ class Version: (?:\.[0-9a-zA-Z-]+)* ))? $ - """, + """ + #: Regex for a semver version + _REGEX = re.compile( + _REGEX_TEMPLATE.format(opt_patch='', opt_minor=''), + re.VERBOSE, + ) + #: Regex for a semver version that might be shorter + _REGEX_OPTIONAL_MINOR_AND_PATCH = re.compile( + _REGEX_TEMPLATE.format(opt_patch='?', opt_minor='?'), re.VERBOSE, ) @@ -553,15 +565,26 @@ def match(self, match_expr: str) -> bool: return cmp_res in possibilities @classmethod - def parse(cls, version: String) -> "Version": + def parse( + cls, + version: String, + optional_minor_and_patch: bool = False + ) -> "Version": """ Parse version string to a Version instance. .. versionchanged:: 2.11.0 Changed method from static to classmethod to allow subclasses. + .. versionchanged:: 3.0.0 + Added optional parameter optional_minor_and_patch to allow optional + minor and patch parts. :param version: version string + :param optional_minor_and_patch: if set to true, the version string to parse \ + can contain optional minor and patch parts. Optional parts are set to zero. + By default (False), the version string to parse has to follow the semver + specification. :return: a new :class:`Version` instance :raises ValueError: if version is invalid :raises TypeError: if version contains the wrong type @@ -575,11 +598,18 @@ def parse(cls, version: String) -> "Version": elif not isinstance(version, String.__args__): # type: ignore raise TypeError("not expecting type '%s'" % type(version)) - match = cls._REGEX.match(version) + if optional_minor_and_patch: + match = cls._REGEX_OPTIONAL_MINOR_AND_PATCH.match(version) + else: + 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() + if not matched_version_parts['minor']: + matched_version_parts['minor'] = 0 + if not matched_version_parts['patch']: + matched_version_parts['patch'] = 0 return cls(**matched_version_parts) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 25c55c74..ddf52196 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -53,6 +53,56 @@ def test_should_parse_version(version, expected): assert result == expected +@pytest.mark.parametrize( + "version,expected", + [ + # no. 1 + ( + "1.2-alpha.1.2+build.11.e0f985a", + { + "major": 1, + "minor": 2, + "patch": 0, + "prerelease": "alpha.1.2", + "build": "build.11.e0f985a", + }, + ), + # no. 2 + ( + "1-alpha-1+build.11.e0f985a", + { + "major": 1, + "minor": 0, + "patch": 0, + "prerelease": "alpha-1", + "build": "build.11.e0f985a", + }, + ), + ( + "0.1-0f", + {"major": 0, "minor": 1, "patch": 0, "prerelease": "0f", "build": None}, + ), + ( + "0-0foo.1", + {"major": 0, "minor": 0, "patch": 0, "prerelease": "0foo.1", "build": None}, + ), + ( + "0-0foo.1+build.1", + { + "major": 0, + "minor": 0, + "patch": 0, + "prerelease": "0foo.1", + "build": "build.1", + }, + ), + ], +) +def test_should_parse_version_with_optional_minor_and_patch(version, expected): + result = Version.parse(version, optional_minor_and_patch=True) + assert result == expected + + def test_parse_version_info_str_hash(): s_version = "1.2.3-alpha.1.2+build.11.e0f985a" v = parse_version_info(s_version)