import datetime import os import re import time import warnings from .config import Configuration from .config import Version as PkgVersion from .utils import iter_entry_points from .utils import trace SEMVER_MINOR = 2 SEMVER_PATCH = 3 SEMVER_LEN = 3 def _parse_version_tag(tag, config): tagstring = tag if isinstance(tag, str) else str(tag) match = config.tag_regex.match(tagstring) result = None if match: if len(match.groups()) == 1: key = 1 else: key = "version" result = { "version": match.group(key), "prefix": match.group(0)[: match.start(key)], "suffix": match.group(0)[match.end(key) :], } trace(f"tag '{tag}' parsed to {result}") return result def callable_or_entrypoint(group, callable_or_name): trace("ep", (group, callable_or_name)) if callable(callable_or_name): return callable_or_name for ep in iter_entry_points(group, callable_or_name): trace("ep found:", ep.name) return ep.load() def tag_to_version(tag, config: "Configuration | None" = None): """ take a tag that might be prefixed with a keyword and return only the version part :param config: optional configuration object """ trace("tag", tag) if not config: config = Configuration() tagdict = _parse_version_tag(tag, config) if not isinstance(tagdict, dict) or not tagdict.get("version", None): warnings.warn(f"tag {tag!r} no version found") return None version = tagdict["version"] trace("version pre parse", version) if tagdict.get("suffix", ""): warnings.warn( "tag {!r} will be stripped of its suffix '{}'".format( tag, tagdict["suffix"] ) ) version = config.version_cls(version) trace("version", repr(version)) return version def tags_to_versions(tags, config=None): """ take tags that might be prefixed with a keyword and return only the version part :param tags: an iterable of tags :param config: optional configuration object """ result = [] for tag in tags: tag = tag_to_version(tag, config=config) if tag: result.append(tag) return result class ScmVersion: def __init__( self, tag_version, distance=None, node=None, dirty=False, preformatted=False, branch=None, config=None, node_date=None, **kw, ): if kw: trace("unknown args", kw) self.tag = tag_version if dirty and distance is None: distance = 0 self.distance = distance self.node = node self.node_date = node_date self.time = datetime.datetime.utcfromtimestamp( int(os.environ.get("SOURCE_DATE_EPOCH", time.time())) ) self._extra = kw self.dirty = dirty self.preformatted = preformatted self.branch = branch self.config = config @property def extra(self): warnings.warn( "ScmVersion.extra is deprecated and will be removed in future", category=DeprecationWarning, stacklevel=2, ) return self._extra @property def exact(self): return self.distance is None def __repr__(self): return self.format_with( "" ) def format_with(self, fmt, **kw): return fmt.format( time=self.time, tag=self.tag, distance=self.distance, node=self.node, dirty=self.dirty, branch=self.branch, node_date=self.node_date, **kw, ) def format_choice(self, clean_format, dirty_format, **kw): return self.format_with(dirty_format if self.dirty else clean_format, **kw) def format_next_version(self, guess_next, fmt="{guessed}.dev{distance}", **kw): guessed = guess_next(self.tag, **kw) return self.format_with(fmt, guessed=guessed) def _parse_tag(tag, preformatted, config: "Configuration|None"): if preformatted: return tag if config is None or not isinstance(tag, config.version_cls): tag = tag_to_version(tag, config) return tag def meta( tag, distance: "int|None" = None, dirty: bool = False, node: "str|None" = None, preformatted: bool = False, branch: "str|None" = None, config: "Configuration|None" = None, **kw, ): if not config: warnings.warn( "meta invoked without explicit configuration," " will use defaults where required." ) parsed_version = _parse_tag(tag, preformatted, config) trace("version", tag, "->", parsed_version) assert parsed_version is not None, "Can't parse version %s" % tag return ScmVersion( parsed_version, distance, node, dirty, preformatted, branch, config, **kw ) def guess_next_version(tag_version: ScmVersion): version = _strip_local(str(tag_version)) return _bump_dev(version) or _bump_regex(version) def _strip_local(version_string): public, sep, local = version_string.partition("+") return public def _bump_dev(version): if ".dev" not in version: return prefix, tail = version.rsplit(".dev", 1) if tail != "0": raise ValueError( "choosing custom numbers for the `.devX` distance " "is not supported.\n " "The {version} can't be bumped\n" "Please drop the tag or create a new supported one".format(version=version) ) return prefix def _bump_regex(version): match = re.match(r"(.*?)(\d+)$", version) if match is None: raise ValueError( "{version} does not end with a number to bump, " "please correct or use a custom version scheme".format(version=version) ) else: prefix, tail = match.groups() return "%s%d" % (prefix, int(tail) + 1) def guess_next_dev_version(version): if version.exact: return version.format_with("{tag}") else: return version.format_next_version(guess_next_version) def guess_next_simple_semver(version, retain, increment=True): try: parts = [int(i) for i in str(version).split(".")[:retain]] except ValueError: raise ValueError(f"{version} can't be parsed as numeric version") while len(parts) < retain: parts.append(0) if increment: parts[-1] += 1 while len(parts) < SEMVER_LEN: parts.append(0) return ".".join(str(i) for i in parts) def simplified_semver_version(version): if version.exact: return guess_next_simple_semver(version.tag, retain=SEMVER_LEN, increment=False) else: if version.branch is not None and "feature" in version.branch: return version.format_next_version( guess_next_simple_semver, retain=SEMVER_MINOR ) else: return version.format_next_version( guess_next_simple_semver, retain=SEMVER_PATCH ) def release_branch_semver_version(version): if version.exact: return version.format_with("{tag}") if version.branch is not None: # Does the branch name (stripped of namespace) parse as a version? branch_ver = _parse_version_tag(version.branch.split("/")[-1], version.config) if branch_ver is not None: branch_ver = branch_ver["version"] if branch_ver[0] == "v": # Allow branches that start with 'v', similar to Version. branch_ver = branch_ver[1:] # Does the branch version up to the minor part match the tag? If not it # might be like, an issue number or something and not a version number, so # we only want to use it if it matches. tag_ver_up_to_minor = str(version.tag).split(".")[:SEMVER_MINOR] branch_ver_up_to_minor = branch_ver.split(".")[:SEMVER_MINOR] if branch_ver_up_to_minor == tag_ver_up_to_minor: # We're in a release/maintenance branch, next is a patch/rc/beta bump: return version.format_next_version(guess_next_version) # We're in a development branch, next is a minor bump: return version.format_next_version(guess_next_simple_semver, retain=SEMVER_MINOR) def release_branch_semver(version): warnings.warn( "release_branch_semver is deprecated and will be removed in future. " + "Use release_branch_semver_version instead", category=DeprecationWarning, stacklevel=2, ) return release_branch_semver_version(version) def no_guess_dev_version(version): if version.exact: return version.format_with("{tag}") else: return version.format_with("{tag}.post1.dev{distance}") def date_ver_match(ver): match = re.match( ( r"^(?P(?P\d{2}|\d{4})(?:\.\d{1,2}){2})" r"(?:\.(?P\d*)){0,1}?$" ), str(ver), ) return match def guess_next_date_ver(version, node_date=None, date_fmt=None, version_cls=None): """ same-day -> patch +1 other-day -> today distance is always added as .devX """ match = date_ver_match(version) if match is None: warnings.warn( f"{version} does not correspond to a valid versioning date, " "assuming legacy version" ) if date_fmt is None: date_fmt = "%y.%m.%d" # deduct date format if not provided if date_fmt is None: date_fmt = "%Y.%m.%d" if len(match.group("year")) == 4 else "%y.%m.%d" head_date = node_date or datetime.date.today() # compute patch if match is None: tag_date = datetime.date.today() else: tag_date = datetime.datetime.strptime(match.group("date"), date_fmt).date() if tag_date == head_date: patch = "0" if match is None else (match.group("patch") or "0") patch = int(patch) + 1 else: if tag_date > head_date and match is not None: # warn on future times warnings.warn( "your previous tag ({}) is ahead your node date ({})".format( tag_date, head_date ) ) patch = 0 next_version = "{node_date:{date_fmt}}.{patch}".format( node_date=head_date, date_fmt=date_fmt, patch=patch ) # rely on the Version object to ensure consistency (e.g. remove leading 0s) if version_cls is None: version_cls = PkgVersion next_version = str(version_cls(next_version)) return next_version def calver_by_date(version): if version.exact and not version.dirty: return version.format_with("{tag}") # TODO: move the release-X check to a new scheme if version.branch is not None and version.branch.startswith("release-"): branch_ver = _parse_version_tag(version.branch.split("-")[-1], version.config) if branch_ver is not None: ver = branch_ver["version"] match = date_ver_match(ver) if match: return ver return version.format_next_version( guess_next_date_ver, node_date=version.node_date, version_cls=version.config.version_cls, ) def _format_local_with_time(version, time_format): if version.exact or version.node is None: return version.format_choice( "", "+d{time:{time_format}}", time_format=time_format ) else: return version.format_choice( "+{node}", "+{node}.d{time:{time_format}}", time_format=time_format ) def get_local_node_and_date(version): return _format_local_with_time(version, time_format="%Y%m%d") def get_local_node_and_timestamp(version, fmt="%Y%m%d%H%M%S"): return _format_local_with_time(version, time_format=fmt) def get_local_dirty_tag(version): return version.format_choice("", "+dirty") def get_no_local_node(_): return "" def postrelease_version(version): if version.exact: return version.format_with("{tag}") else: return version.format_with("{tag}.post{distance}") def _get_ep(group, name): for ep in iter_entry_points(group, name): trace("ep found:", ep.name) return ep.load() def _iter_version_schemes(entrypoint, scheme_value, _memo=None): if _memo is None: _memo = set() if isinstance(scheme_value, str): scheme_value = _get_ep(entrypoint, scheme_value) if isinstance(scheme_value, (list, tuple)): for variant in scheme_value: if variant not in _memo: _memo.add(variant) yield from _iter_version_schemes(entrypoint, variant, _memo=_memo) elif callable(scheme_value): yield scheme_value def _call_version_scheme(version, entypoint, given_value, default): for scheme in _iter_version_schemes(entypoint, given_value): result = scheme(version) if result is not None: return result return default def format_version(version, **config): trace("scm version", version) trace("config", config) if version.preformatted: return version.tag main_version = _call_version_scheme( version, "setuptools_scm.version_scheme", config["version_scheme"], None ) trace("version", main_version) assert main_version is not None local_version = _call_version_scheme( version, "setuptools_scm.local_scheme", config["local_scheme"], "+unknown" ) trace("local_version", local_version) return main_version + local_version