diff --git a/semver.py b/semver.py index 0c98af97..64b00cdd 100644 --- a/semver.py +++ b/semver.py @@ -3,12 +3,11 @@ import argparse import collections -from functools import wraps, partial import inspect import re import sys import warnings - +from functools import wraps, partial __version__ = "2.10.1" __author__ = "Kostiantyn Rybnikov" @@ -49,6 +48,11 @@ "VersionInfo", ) +from types import FrameType + +from typing import Callable, TypeVar, SupportsInt, Optional, Tuple, Dict, Union, Generator, cast, Iterable, \ + List + #: Contains the implemented semver.org version of the spec SEMVER_SPEC_VERSION = "2.0.0" @@ -60,11 +64,15 @@ def cmp(a, b): return (a > b) - (a < b) -def deprecated(func=None, replace=None, version=None, category=DeprecationWarning): +F = TypeVar('F', bound=Callable) + + +def deprecated(func: F = None, replace: str = None, version: str = None, category=DeprecationWarning + ) -> Union[Callable[..., F], partial]: """ Decorates a function to output a deprecation warning. - :param func: the function to decorate (or None) + :param func: the function to decorate :param str replace: the function to replace (use the full qualified name like ``semver.VersionInfo.bump_major``. :param str version: the first version when this function was deprecated. @@ -77,24 +85,25 @@ def deprecated(func=None, replace=None, version=None, category=DeprecationWarnin return partial(deprecated, replace=replace, version=version, category=category) @wraps(func) - def wrapper(*args, **kwargs): - msg = ["Function '{m}.{f}' is deprecated."] + def wrapper(*args, **kwargs) -> Callable[..., F]: + msg_list = ["Function '{m}.{f}' is deprecated."] if version: - msg.append("Deprecated since version {v}. ") - msg.append("This function will be removed in semver 3.") + msg_list.append("Deprecated since version {v}. ") + msg_list.append("This function will be removed in semver 3.") if replace: - msg.append("Use {r!r} instead.") + msg_list.append("Use {r!r} instead.") else: - msg.append("Use the respective 'semver.VersionInfo.{r}' instead.") + msg_list.append("Use the respective 'semver.VersionInfo.{r}' instead.") # hasattr is needed for Python2 compatibility: - f = func.__qualname__ if hasattr(func, "__qualname__") else func.__name__ + f = func.__qualname__ if hasattr(func, "__qualname__") else func.__name__ # type: ignore r = replace or f - frame = inspect.currentframe().f_back + frame = cast(FrameType, cast(FrameType, inspect.currentframe()).f_back) - msg = " ".join(msg) + msg = " ".join(msg_list) + cast(str, msg) warnings.warn_explicit( msg.format(m=func.__module__, f=f, r=r, v=version), category=category, @@ -105,7 +114,7 @@ def wrapper(*args, **kwargs): # https://docs.python.org/3/library/inspect.html#the-interpreter-stack # better remove the interpreter stack: del frame - return func(*args, **kwargs) + return func(*args, **kwargs) # type: ignore return wrapper @@ -139,11 +148,15 @@ def parse(version): return VersionInfo.parse(version).to_dict() -def comparator(operator): +Comparable = Union['VersionInfo', dict, tuple, list, str] +Comparator = Callable[['VersionInfo', Comparable], bool] + + +def comparator(operator: Comparator) -> Comparator: """Wrap a VersionInfo binary op method in a type-check.""" @wraps(operator) - def wrapper(self, other): + def wrapper(self: 'VersionInfo', other: Comparable) -> bool: comparable_types = (VersionInfo, dict, tuple, list, str) if not isinstance(other, comparable_types): raise TypeError( @@ -154,7 +167,7 @@ def wrapper(self, other): return wrapper -class VersionInfo(object): +class VersionInfo: """ A semver compatible version class. @@ -191,7 +204,8 @@ class VersionInfo(object): re.VERBOSE, ) - def __init__(self, major, minor=0, patch=0, prerelease=None, build=None): + def __init__(self, major: SupportsInt, minor: SupportsInt = 0, patch: SupportsInt = 0, + prerelease: Union[str, int] = None, build: Union[str, int] = None): self._major = int(major) self._minor = int(minor) self._patch = int(patch) @@ -199,7 +213,7 @@ def __init__(self, major, minor=0, patch=0, prerelease=None, build=None): self._build = None if build is None else str(build) @property - def major(self): + def major(self) -> int: """The major part of a version (read-only).""" return self._major @@ -208,7 +222,7 @@ def major(self, value): raise AttributeError("attribute 'major' is readonly") @property - def minor(self): + def minor(self) -> int: """The minor part of a version (read-only).""" return self._minor @@ -217,7 +231,7 @@ def minor(self, value): raise AttributeError("attribute 'minor' is readonly") @property - def patch(self): + def patch(self) -> int: """The patch part of a version (read-only).""" return self._patch @@ -226,7 +240,7 @@ def patch(self, value): raise AttributeError("attribute 'patch' is readonly") @property - def prerelease(self): + def prerelease(self) -> Optional[str]: """The prerelease part of a version (read-only).""" return self._prerelease @@ -235,7 +249,7 @@ def prerelease(self, value): raise AttributeError("attribute 'prerelease' is readonly") @property - def build(self): + def build(self) -> Optional[str]: """The build part of a version (read-only).""" return self._build @@ -243,7 +257,7 @@ def build(self): def build(self, value): raise AttributeError("attribute 'build' is readonly") - def to_tuple(self): + def to_tuple(self) -> Tuple[int, int, int, Optional[str], Optional[str]]: """ Convert the VersionInfo object to a tuple. @@ -259,7 +273,7 @@ def to_tuple(self): """ return (self.major, self.minor, self.patch, self.prerelease, self.build) - def to_dict(self): + def to_dict(self) -> Dict[str, Union[int, Optional[str]]]: """ Convert the VersionInfo object to an OrderedDict. @@ -298,14 +312,14 @@ def _asdict(self): _asdict.__doc__ = to_dict.__doc__ - def __iter__(self): + def __iter__(self) -> Generator[Union[int, Optional[str]], None, None]: """Implement iter(self).""" # As long as we support Py2.7, we can't use the "yield from" syntax for v in self.to_tuple(): yield v @staticmethod - def _increment_string(string): + def _increment_string(string: str) -> str: """ Look for the last sequence of number(s) in a string and increment. @@ -322,7 +336,7 @@ def _increment_string(string): string = string[: max(end - len(next_), start)] + next_ + string[end:] return string - def bump_major(self): + def bump_major(self) -> 'VersionInfo': """ Raise the major part of the version, return a new object but leave self untouched. @@ -337,7 +351,7 @@ def bump_major(self): cls = type(self) return cls(self._major + 1) - def bump_minor(self): + def bump_minor(self) -> 'VersionInfo': """ Raise the minor part of the version, return a new object but leave self untouched. @@ -352,7 +366,7 @@ def bump_minor(self): cls = type(self) return cls(self._major, self._minor + 1) - def bump_patch(self): + def bump_patch(self) -> 'VersionInfo': """ Raise the patch part of the version, return a new object but leave self untouched. @@ -367,7 +381,7 @@ def bump_patch(self): cls = type(self) return cls(self._major, self._minor, self._patch + 1) - def bump_prerelease(self, token="rc"): + def bump_prerelease(self, token: str = "rc") -> 'VersionInfo': """ Raise the prerelease part of the version, return a new object but leave self untouched. @@ -385,7 +399,7 @@ def bump_prerelease(self, token="rc"): prerelease = cls._increment_string(self._prerelease or (token or "rc") + ".0") return cls(self._major, self._minor, self._patch, prerelease) - def bump_build(self, token="build"): + def bump_build(self, token: str = "build") -> 'VersionInfo': """ Raise the build part of the version, return a new object but leave self untouched. @@ -403,7 +417,7 @@ def bump_build(self, token="build"): build = cls._increment_string(self._build or (token or "build") + ".0") return cls(self._major, self._minor, self._patch, self._prerelease, build) - def compare(self, other): + def compare(self, other: Comparable) -> int: """ Compare self with other. @@ -454,7 +468,7 @@ def compare(self, other): return rccmp - def next_version(self, part, prerelease_token="rc"): + def next_version(self, part: str, prerelease_token: str ="rc") -> 'VersionInfo': """ Determines next version, preserving natural order. @@ -503,30 +517,30 @@ def next_version(self, part, prerelease_token="rc"): return version.bump_prerelease(prerelease_token) @comparator - def __eq__(self, other): + def __eq__(self, other: Comparable) -> bool: # type: ignore return self.compare(other) == 0 @comparator - def __ne__(self, other): + def __ne__(self, other: Comparable) -> bool: # type: ignore return self.compare(other) != 0 @comparator - def __lt__(self, other): + def __lt__(self, other: Comparable) -> bool: return self.compare(other) < 0 @comparator - def __le__(self, other): + def __le__(self, other: Comparable) -> bool: return self.compare(other) <= 0 @comparator - def __gt__(self, other): + def __gt__(self, other: Comparable) -> bool: return self.compare(other) > 0 @comparator - def __ge__(self, other): + def __ge__(self, other: Comparable) -> bool: return self.compare(other) >= 0 - def __getitem__(self, index): + def __getitem__(self, index: Union[int, slice]) -> Union[int, Optional[str], Tuple[Union[int, str], ...]]: """ self.__getitem__(index) <==> self[index] @@ -545,6 +559,7 @@ def __getitem__(self, index): """ if isinstance(index, int): index = slice(index, index + 1) + index = cast(slice, index) if ( isinstance(index, slice) @@ -553,19 +568,19 @@ def __getitem__(self, index): ): raise IndexError("Version index cannot be negative") - part = tuple(filter(lambda p: p is not None, self.to_tuple()[index])) + part = tuple(filter(lambda p: p is not None, cast(Iterable, self.to_tuple()[index]))) if len(part) == 1: - part = part[0] - elif not part: + return part[0] + if not part: raise IndexError("Version part undefined") return part - def __repr__(self): + def __repr__(self) -> str: s = ", ".join("%s=%r" % (key, val) for key, val in self.to_dict().items()) return "%s(%s)" % (type(self).__name__, s) - def __str__(self): + def __str__(self) -> str: """str(self)""" version = "%d.%d.%d" % (self.major, self.minor, self.patch) if self.prerelease: @@ -574,10 +589,10 @@ def __str__(self): version += "+%s" % self.build return version - def __hash__(self): + def __hash__(self) -> int: return hash(self.to_tuple()) - def finalize_version(self): + def finalize_version(self) -> 'VersionInfo': """ Remove any prerelease and build metadata from the version. @@ -590,7 +605,7 @@ def finalize_version(self): cls = type(self) return cls(self.major, self.minor, self.patch) - def match(self, match_expr): + def match(self, match_expr: str) -> bool: """ Compare self to match a match expression. @@ -638,7 +653,7 @@ def match(self, match_expr): return cmp_res in possibilities @staticmethod - def parse(version): + def parse(version: str) -> 'VersionInfo': """ Parse version string to a VersionInfo instance. @@ -655,15 +670,15 @@ def parse(version): if match is None: raise ValueError("%s is not valid SemVer string" % version) - version_parts = match.groupdict() + matched_version_parts = match.groupdict() - version_parts["major"] = int(version_parts["major"]) - version_parts["minor"] = int(version_parts["minor"]) - version_parts["patch"] = int(version_parts["patch"]) + version_parts = {"major": int(matched_version_parts["major"]), + "minor": int(matched_version_parts["minor"]), + "patch": int(matched_version_parts["patch"])} return VersionInfo(**version_parts) - def replace(self, **parts): + def replace(self, **parts: Union[int, Optional[str]]) -> 'VersionInfo': """ Replace one or more parts of a version and return a new :class:`VersionInfo` object, but leave self untouched @@ -680,7 +695,7 @@ def replace(self, **parts): version = self.to_dict() version.update(parts) try: - return VersionInfo(**version) + return VersionInfo(**version) # type: ignore except TypeError: unknownkeys = set(parts) - set(self.to_dict()) error = "replace() got %d unexpected keyword " "argument(s): %s" % ( @@ -690,7 +705,7 @@ def replace(self, **parts): raise TypeError(error) @classmethod - def isvalid(cls, version): + def isvalid(cls, version: str) -> bool: """ Check if the string is a valid semver version. @@ -738,14 +753,14 @@ def parse_version_info(version): return VersionInfo.parse(version) -def _nat_cmp(a, b): - def convert(text): - return int(text) if re.match("^[0-9]+$", text) else text +def _nat_cmp(a: Optional[str], b: Optional[str]) -> int: + def convert(text: str) -> Union[int, str]: + return int(text) if re.match("^[0-9]+$", text) else text # type: ignore - def split_key(key): + def split_key(key: str) -> List[Union[int, str]]: return [convert(c) for c in key.split(".")] - def cmp_prerelease_tag(a, b): + def cmp_prerelease_tag(a: Union[int, str], b: Union[int, str]) -> int: if isinstance(a, int) and isinstance(b, int): return cmp(a, b) elif isinstance(a, int): @@ -1017,7 +1032,7 @@ def replace(version, **parts): # ---- CLI -def cmd_bump(args): +def cmd_bump(args: argparse.Namespace) -> str: """ Subcommand: Bumps a version. @@ -1042,11 +1057,11 @@ def cmd_bump(args): ver = VersionInfo.parse(args.version) # get the respective method and call it - func = getattr(ver, maptable[args.bump]) + func = getattr(ver, maptable[cast(str, args.bump)]) return str(func()) -def cmd_check(args): +def cmd_check(args: argparse.Namespace) -> None: """ Subcommand: Checks if a string is a valid semver version. @@ -1060,7 +1075,7 @@ def cmd_check(args): raise ValueError("Invalid version %r" % args.version) -def cmd_compare(args): +def cmd_compare(args: argparse.Namespace) -> str: """ Subcommand: Compare two versions @@ -1072,7 +1087,7 @@ def cmd_compare(args): return str(compare(args.version1, args.version2)) -def cmd_nextver(args): +def cmd_nextver(args: argparse.Namespace) -> str: """ Subcommand: Determines the next version, taking prereleases into account. @@ -1085,7 +1100,7 @@ def cmd_nextver(args): return str(version.next_version(args.part)) -def createparser(): +def createparser() -> argparse.ArgumentParser: """ Create an :class:`argparse.ArgumentParser` instance. @@ -1112,11 +1127,11 @@ def createparser(): # Create subparsers for the bump subparser: for p in ( - sb.add_parser("major", help="Bump the major part of the version"), - sb.add_parser("minor", help="Bump the minor part of the version"), - sb.add_parser("patch", help="Bump the patch part of the version"), - sb.add_parser("prerelease", help="Bump the prerelease part of the version"), - sb.add_parser("build", help="Bump the build part of the version"), + sb.add_parser("major", help="Bump the major part of the version"), + sb.add_parser("minor", help="Bump the minor part of the version"), + sb.add_parser("patch", help="Bump the patch part of the version"), + sb.add_parser("prerelease", help="Bump the prerelease part of the version"), + sb.add_parser("build", help="Bump the build part of the version"), ): p.add_argument("version", help="Version to raise") @@ -1139,7 +1154,7 @@ def createparser(): return parser -def process(args): +def process(args: argparse.Namespace) -> str: """ Process the input from the CLI. @@ -1158,7 +1173,7 @@ def process(args): return args.func(args) -def main(cliargs=None): +def main(cliargs: List[str] = None) -> int: """ Entry point for the application script.